September 2, 2026

ESLint 10: What Breaks When You Upgrade (and Why ESLint 9 Is Already Unsafe)

Photo of Marco Orta Marco Orta | 11 min read
Compartir
Two stacked indigo glass config files: the lower one crumbling into fragments while the upper one stays intact and glowing
Table of Contents

    ESLint 9 reached end of life on August 6, 2026. If your CI is still running eslint@9, it has been running without security patches for four weeks, and nobody is going to tell you. ESLint 10 went GA on February 6, 2026, which means the six-month overlap window the project always gives you is already gone. The headline change is not a new rule or a faster parser — it’s that the eslintrc configuration system, the one built around .eslintrc.json and .eslintrc.js, has been deleted from the codebase. Not deprecated. Not gated behind a flag. Deleted. There is exactly one way to configure ESLint 10: eslint.config.js.

    This matters more than most major-version bumps because ESLint has been telegraphing this exact removal since flat config shipped as the default in v9, back in April 2024. Teams had two years of warning. A lot of them used that time to do nothing, because the old config kept working. It stops working now.

    Where are you coming from?

    The migration cost splits hard across three starting points.

    From ESLint 9 with flat config already in place. You’re in the best position. Your eslint.config.js already exists and already works the way ESLint 10 expects. What still hits you: the Node.js version floor moved, the default rule set gained three rules you haven’t seen before, and — if you’re in a monorepo — the config-file lookup algorithm changed in a way that can silently change which rules apply to which files.

    From ESLint 9 still leaning on @eslint/eslintrc’s FlatCompat shim. This was the pragmatic middle path a lot of teams took in 2024–2025: write an eslint.config.js, but use FlatCompat.extends() inside it to keep pulling in an old-style shareable config (airbnb-base, an internal eslint-config-company package) that never got a flat-config release. The good news, and it’s not obvious, is that this path still works on ESLint 10 — FlatCompat didn’t die with eslintrc, it’s a separate package and it’s still maintained. You do need to make sure @eslint/eslintrc itself is on a current version, since old copies predate ESLint 10’s internals.

    From ESLint 8, or from ESLint 9 with no eslint.config.js at all (meaning you were relying on ESLint 9’s automatic fallback to the legacy format when it doesn’t find a flat config). This is the group that breaks hardest, because that fallback is exactly what got removed. Point ESLint 10 at a project with only .eslintrc.json and no eslint.config.js, and it won’t quietly use the old file — it will fail to find any configuration at all.

    The breaking changes, ranked by how much they’ll cost you

    1. eslintrc is gone. There is no flag to bring it back.

    This is the one everyone half-expects and still gets bitten by, because the actual removal is broader than “the old file format stops being read.” Four things go away together:

    • .eslintrc.js, .eslintrc.json, .eslintrc.yml, and .eslintignore are no longer read, full stop.
    • The ESLINT_USE_FLAT_CONFIG environment variable, which used to let you force ESLint 9 back onto the legacy system, is no longer honored. Setting it does nothing — it’s not an error, it’s just ignored.
    • The CLI flags that only made sense for eslintrc are gone: --no-eslintrc, --env, --resolve-plugins-relative-to, --rulesdir, --ignore-path. Scripts that pass these now fail to start.
    • Linter’s configType constructor option only accepts "flat". Pass "eslintrc" and it throws.

    The migration itself, from .eslintrc.json to eslint.config.js, is mechanical once you’ve done it once. Here’s a real one, before and after:

    Before — .eslintrc.json:

    {
      "root": true,
      "env": {
        "browser": true,
        "es2021": true,
        "node": true
      },
      "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
      "parser": "@typescript-eslint/parser",
      "parserOptions": {
        "ecmaVersion": "latest",
        "sourceType": "module"
      },
      "plugins": ["@typescript-eslint"],
      "rules": {
        "no-unused-vars": "off",
        "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }]
      },
      "ignorePatterns": ["dist", "node_modules"]
    }
    

    After — eslint.config.js:

    import js from '@eslint/js';
    import globals from 'globals';
    import tseslint from 'typescript-eslint';
    
    export default tseslint.config(
      {
        ignores: ['dist/**', 'node_modules/**'],
      },
      js.configs.recommended,
      ...tseslint.configs.recommended,
      {
        languageOptions: {
          globals: {
            ...globals.browser,
            ...globals.node,
          },
        },
        rules: {
          '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
        },
      },
    );
    

    Three things changed shape, not just syntax: env became languageOptions.globals pulled from the globals package, extends became an array you spread in directly (tseslint.configs.recommended is already an array of config objects), and no-unused-vars: off disappeared — @typescript-eslint/no-unused-vars doesn’t need you to manually silence the base rule anymore because tseslint.configs.recommended already scopes it correctly per file.

    If your only remaining blocker is a shareable config with no flat-config release, that’s what FlatCompat is for:

    import { FlatCompat } from '@eslint/eslintrc';
    import path from 'node:path';
    import { fileURLToPath } from 'node:url';
    
    const compat = new FlatCompat({
      baseDirectory: path.dirname(fileURLToPath(import.meta.url)),
    });
    
    export default [
      ...compat.extends('airbnb-base'),
      // the rest of your flat config
    ];
    

    That’s still a real eslint.config.jsFlatCompat just lets one old-format shareable config live inside it. It’s a bridge, not eslintrc coming back.

    2. The config-lookup algorithm changed — silent in monorepos

    This one is silent by design, and it’s the change most likely to bite people who did everything “right.” ESLint 10 stopped resolving eslint.config.* from the current working directory. Instead it starts the search from the directory of each file being linted and walks upward. The stated goal is to let a monorepo have a different eslint.config.js per package without extra wiring — which is genuinely useful. The cost is that a file can now silently pick up a different config than it did on ESLint 9, with no warning and no error: the lint just passes or fails differently than before, and the reason is a config file two directories away that you forgot existed. If you run a monorepo, lint every workspace individually right after the upgrade and diff the output against ESLint 9 before you trust it.

    3. Three rules just turned on in eslint:recommended — also silent

    If your config includes js.configs.recommended (formerly eslint:recommended), upgrading adds no-unassigned-vars, no-useless-assignment, and preserve-caught-error to what you’re enforcing — without you touching a single line of your own config. This is the classic silent break: you run the exact command you always run, and it now fails or warns on code that hasn’t changed. If your CI treats warnings as failures, that’s a red build the morning after the bump, with a diff that shows nothing you did wrong.

    no-useless-assignment catches the value that’s overwritten before it’s ever read:

    function total(items) {
      let sum = 0; // this assignment...
      sum = items.reduce((a, b) => a + b, 0); // ...is immediately replaced, never read
      return sum;
    }
    

    preserve-caught-error flags a caught error you throw away instead of chaining:

    // before — the original error and its stack are gone
    try {
      parseConfig(raw);
    } catch (err) {
      throw new Error('Config parsing failed');
    }
    
    // after — the original cause survives for whoever debugs this later
    try {
      parseConfig(raw);
    } catch (err) {
      throw new Error('Config parsing failed', { cause: err });
    }
    

    None of this is wrong to enforce — it’s good advice. The problem is finding out about it from a failed deploy instead of from a changelog.

    4. /* eslint-env */ comments go from silently inert to an explicit error

    This one has a two-step history worth knowing. Flat config never supported /* eslint-env browser */-style comments — they’ve been silently doing nothing since you moved to flat config on ESLint 9, whether or not you noticed. ESLint 10 closes that silence: the same comment is now reported as a lint error instead of being quietly ignored. If you never migrated those comments to languageOptions.globals, you’ll see brand-new errors pointing at code that “worked” for the last year and a half, because the globals it needs were never actually being declared — the comment just wasn’t doing anything.

    /* eslint-env browser, node */
    window.dispatchEvent(new Event('ready'));
    

    Move the intent into the config instead, the same way the .eslintrc example above did with env:

    {
      languageOptions: {
        globals: { ...globals.browser, ...globals.node },
      },
    }
    

    5. The Node.js floor moved, and it’s not just a version-number bump

    ESLint 10 requires Node.js ^20.19.0 || ^22.13.0 || >=24. Node 21.x and 23.x — the odd-numbered non-LTS releases — are unsupported outright, and anything below 20.19.0 or 22.13.0 is unsupported even within an otherwise-fine major. In package.json:

    // before
    "engines": { "node": ">=18" }
    
    // after
    "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }
    

    If your CI image pins an older Node patch inside 20.x or 22.x, npm install may still succeed while the linter itself fails at startup with an error that has nothing obviously to do with Node — install ESLint and it’s already broken before it reads a single file.

    6. Removed context and SourceCode methods break custom rules and old plugins

    If you write custom rules, or depend on an internal plugin nobody has touched since 2023, this is where it bites: context.getCwd(), context.getFilename(), context.getPhysicalFilename(), context.getSourceCode(), context.parserOptions, and context.parserPath are all gone, along with SourceCode#getTokenOrCommentBefore(), getTokenOrCommentAfter(), isSpaceBetweenTokens(), and getJSDocComment(). The Linter class itself lost defineParser(), defineRule(), defineRules(), and getRules(). Replacements exist for the ones that matter (context.cwd, context.filename, context.sourceCode, isSpaceBetween()), but a rule that only calls the removed API on a rare code path won’t throw until that path runs — which can mean it passes CI for weeks and then fails on one specific file.

    7. eslint.config.ts needs a current jiti

    ESLint 10 drops support for jiti versions before 2.2.0, which matters only if you write your config in TypeScript (eslint.config.ts) and let jiti transpile it on the fly. Under 2.2.0, the failure doesn’t say “upgrade jiti” — it surfaces as an unrelated module-resolution error, which sends most people down the wrong debugging path first.

    What doesn’t break

    Worth saying plainly, because the framing of “everything breaks” isn’t accurate either. typescript-eslint already supports ESLint 10 (its stated peer range is ^8.57.0 || ^9.0.0 || ^10.0.0), so a project on modern typescript-eslint doesn’t need to touch it for this upgrade. And FlatCompat — the actual escape hatch, as covered above — is alive and shipping.

    The one place this stack forces your hand is Astro. eslint-plugin-astro’s current release requires ESLint 10 outright, so an Astro project that upgrades the Astro plugin for any other reason drags ESLint along with it whether the ESLint bump was planned or not. Check both together before you touch either.

    When should you upgrade?

    Right now, if you’re still on ESLint 9 or earlier. The EOL already happened a month ago as of this writing; there’s no “wait for the dust to settle” version of that argument left.

    This week, if you’re on ESLint 9 with flat config already migrated. Your risk surface is the monorepo lookup change and the three new default rules — both are a lint run away from being visible, not a rewrite.

    After an audit, if you maintain custom rules or an internal plugin. Grep your rule implementations for getCwd, getFilename, getSourceCode, and parserOptions before you bump the version, not after CI tells you.

    In a dedicated branch first, if you run a monorepo. Lint each workspace in isolation, diff the results against ESLint 9, and only then merge — the config-lookup change is exactly the kind of thing that looks fine locally and misbehaves in whichever package you didn’t personally test.

    With FlatCompat as a bridge, if you depend on a shareable config that never shipped flat support. That’s not a reason to stay on an unsupported major — it’s the tool that lets you upgrade today and finish the real migration later.

    Further reading:

    Frequently asked questions

    Compartir

    Search

    Tags

    PHP Tutorial Laravel JavaScript AI Migration Web Development Best Practices Upgrade Security Laravel 13 Backend SEO Claude Tools