Home / Articles

Modern JavaScript's Real Complexity Comes From Tooling, Not the Language

This article explains how core JavaScript features like async/await and optional chaining simplify code, while excess tooling and dependencies create needless complexity.

Publisher: REACTAPP.TOP17 min read3951 words

This article is published in English.

Sources: Modern JavaScript Is Simpler Than We Pretend

There is a strange moment in JavaScript development when you realize you know too much.

Not too much JavaScript itself.

Too much of the ecosystem surrounding JavaScript.

You picked up React. Then hooks came along. Then state management entered the picture, followed by a favorite state management library. You developed strong opinions about fetching data on the client. You understand what a bundler does even though you rarely open its config file. You've encountered server components, signals, islands, edge runtimes, meta-frameworks, build tooling, linters, formatters, package managers, and no fewer than three competing conventions for organizing a folder named components.

Then you open a tiny JavaScript file and realize the actual logic boils down to this:

const users = await fetch("/api/users").then(res => res.json());

That's the whole thing.

The language on its own is often surprisingly straightforward.

What makes modern JavaScript feel overwhelming is everything layered on top of it.

Watching JavaScript codebases balloon from a handful of files into systems carrying hundreds of dependencies suggests we've mixed up sophistication with genuine complexity.

Working with modern JavaScript doesn't mean adopting every new feature it offers.

It means recognizing which problems the language has already taken care of for you.

The language has quietly grown up

JavaScript once had a reputation as an odd, hastily designed language that somehow ended up powering the entire web.

Some of that reputation was fair.

Early versions had real friction. Browser APIs didn't behave consistently across environments. Reasoning about asynchronous code was genuinely hard. The runtime lacked built-in modules, so large codebases relied on patterns that frequently felt more convoluted than the language itself.

Today's JavaScript is a different story entirely.

Here's what you now have available:

  • block-scoped variables through const and let
  • concise function syntax via arrow functions
  • destructuring for pulling values out of objects and arrays
  • string interpolation with template literals
  • default parameters so you skip manual fallback checks
  • spread syntax for copying and merging data
  • a native module system
  • classes, reserved for the moments they're genuinely warranted
  • a built-in way to represent asynchronous work through promises
  • syntax that reads asynchronous work like ordinary steps, namely async and await
  • safe property access using optional chaining
  • a cleaner way to fall back on default values, nullish coalescing
  • array and collection helpers built into the language
  • true privacy inside classes via private fields
  • generators and iterators for custom sequences
  • dedicated Map and Set data structures
  • solid, native JSON support
  • runtimes that keep getting more capable

No single feature on that list makes the language hard to work with.

The real issue is that developers tend to memorize these as a sprawling pile of syntax instead of treating them as a fairly compact toolkit for expressing everyday programming concepts.

Once the underlying ideas click, the syntax stops feeling intimidating.

You probably don't need half the architecture

One of the fastest ways to overcomplicate a JavaScript project is jumping to architecture decisions before you've even understood the problem.

Picture a dashboard: it needs to load some data, let a user edit it, and save the result.

A sensible first pass might look like this:

async function loadUsers() {
  const response = await fetch("/api/users");
  if (!response.ok) {
    throw new Error("Failed to load users");
  }  return response.json();
}

Then, calling it is as simple as:

const users = await loadUsers();

You can build a surprising amount of functional software on top of that pattern alone.

Yet modern habits push us to immediately ask a barrage of questions instead:

Should you reach for a framework right away?

What about a layer for pulling in data?

Do you need a dedicated library just to manage state?

Should there be a caching mechanism sitting in front of your requests?

Is a schema-validation library required?

How should folders and files be organized?

Which pattern should your components follow?

What build tooling should assemble everything?

What approach should the server use to handle requests?

These questions can matter eventually.

They're rarely the right questions to start with.

The first question should always be:

What does this application actually require?

That distinction is important because every abstraction has a price tag.

Each one adds a concept that someone else will need to learn before they can touch the code safely.

If a plain function does the job, you probably don't need a class.

If a module does the job, you probably don't need a framework.

If a plain object can hold your state, you probably don't need a state-management system.

If fetch() covers your needs, another data-fetching layer might just be extra distance between you and the network.

None of this means frameworks are inherently a bad idea.

It means a framework should be answering a problem you actually have, not one you assumed you'd have.

async and await removed a huge amount of mental overhead

Not every major upgrade in modern JavaScript is flashy.

async and await are a great example of a quiet, massive improvement.

Look back at the old callback-based approach:

getUser(id, function (user) {
  getOrders(user, function (orders) {
    processOrders(orders, function (result) {
      console.log(result);
    });
  });
});

The trouble wasn't just that this code was visually unpleasant.

Deeply nested callbacks made it genuinely hard to trace the flow of execution.

Promises made things better:

getUser(id)
  .then(user => getOrders(user))
  .then(orders => processOrders(orders))
  .then(result => console.log(result));

And then async and await pushed asynchronous code even closer to plain, sequential-looking logic:

const user = await getUser(id);
const orders = await getOrders(user);
const result = await processOrders(orders);
console.log(result);

This is the kind of feature that fades into the background once it becomes habit.

That's actually a sign of good design.

Well-designed language features tend to disappear into ordinary use rather than calling attention to themselves.

The mistake is assuming that because asynchronous behavior is inherently tricky, the syntax wrapping it has to be tricky too.

Most of the time, it isn't.

Destructuring is not a personality test

Some developers lean on destructuring so hard that ordinary code becomes harder to parse.

Take this:

const { name, email, address: { city } } = user;

It has its place.

But other times, this reads more clearly:

const name = user.name;
const email = user.email;
const city = user.address.city;

There's no bonus points for cramming more syntax into a single line.

Destructuring earns its keep when it makes the connection between the code and the underlying data clearer.

For instance:

function createUser({ name, email }) {
  // ...
}

That's expressive.

It tells the reader exactly what shape of object the function expects.

But once destructuring gets nested deep enough that the reader has to mentally rebuild the original object structure, the feature stops helping and starts working against you.

Modern JavaScript stays simple when you treat syntax as a way to communicate, not as decoration.

Optional chaining solved a real problem

Some features genuinely cut down on clutter.

Optional chaining is a good example.

Before it existed, you'd often see something like:

if (
  user &&
  user.profile &&
  user.profile.settings &&
  user.profile.settings.theme
) {
  return user.profile.settings.theme;
}

Now you can write:

return user?.profile?.settings?.theme;

That isn't just shorter.

It states the intent directly.

The developer is effectively saying:

This path might not exist. If it doesn't, bail out safely.

The same logic applies to nullish coalescing:

const pageSize = options.pageSize ?? 20;

which is frequently more precise than:

const pageSize = options.pageSize || 20;

because || treats falsy values like 0 or "" as if they were missing, whereas ?? only reacts to null or undefined.

Features like these are worth learning because they eliminate accidental complexity rather than adding stylistic flourish.

Here's a useful filter to apply to any new syntax:

Does this make the code easier to reason about?

If the answer is yes, learn it.

If it only makes the code look impressive, proceed with caution.

Modern JavaScript does not mean functional-programming gymnastics

There's another common source of avoidable complexity: the assumption that modern JavaScript has to be written in a heavily functional style.

You'll often encounter something like:

const result = users
  .filter(user => user.active)
  .map(user => ({
    ...user,
    displayName: `${user.firstName} ${user.lastName}`
  }))
  .reduce((groups, user) => {
    const key = user.department;
    groups[key] ??= [];
    groups[key].push(user);
    return groups;
  }, {});

There's nothing wrong with that on its own.

But it's easy to forget that a plain loop is still perfectly valid JavaScript.

This version can be just as reasonable:

const result = {};
for (const user of users) {
  if (!user.active) continue;  const department = user.department;  if (!result[department]) {
    result[department] = [];
  }  result[department].push({
    ...user,
    displayName: `${user.firstName} ${user.lastName}`
  });
}

The loop version takes more lines.

It may also be simpler to step through when debugging.

Professional code isn't judged by how few lines it uses.

It's judged by how little unnecessary cognitive effort it demands from whoever reads it next.

Sometimes map, filter, and reduce reduce that effort.

Sometimes a loop does the job better.

Knowing when to reach for which tool matters more than memorizing every array method available.

JavaScript's object model is easier once you stop fighting it

Much of the confusion around JavaScript comes from trying to squeeze it into the mental model of a different language.

JavaScript objects can simply hold data:

const user = {
  name: "Sarah",
  age: 31
};

They can also hold functions:

const user = {
  name: "Sarah",
  greet() {
    return `Hello, ${this.name}`;
  }
};

You can define classes:

class User {
  constructor(name) {
    this.name = name;
  }
  greet() {
    return `Hello, ${this.name}`;
  }
}

But classes aren't mandatory for every situation.

A plain object paired with a factory function is often just as clear:

function createUser(name) {
  return {
    name,
    greet() {
      return `Hello, ${name}`;
    }
  };
}

Neither style wins in every case.

The real skill is recognizing what a given piece of code actually needs.

Classes make sense when you're dealing with real object identity, behavior, lifecycle management, inheritance, or encapsulation.

Plain objects tend to suit simple data.

Functions tend to suit transformations.

Modules tend to suit boundaries between parts of a system.

You don't have to turn every concept into a class just because the language happens to support them.

The module system counts among JavaScript's strongest features

Native modules quietly resolve an issue that once generated a lot of unnecessary complexity.

Consider this pattern:

// math.js
export function add(a, b) {
  return a + b;
}

Paired with:

// app.js
import { add } from "./math.js";
console.log(add(2, 3));

This setup produces a clean dependency boundary. The importing module only pulls in what it actually needs. The dependency itself stays visible in the code. You can test the function on its own. And none of this requires stuffing everything into a sprawling global namespace.

What makes this significant is that you don't need a framework to get modularity. The language itself lets you draw clear lines between parts of your system. A project might be organized like:

src/
  users.js
  orders.js
  payments.js
  validation.js
  config.js
  app.js

without forcing every file to demonstrate a design pattern.

A well-structured module usually makes three things obvious just by looking at it: what piece of functionality belongs to it, which parts of that functionality it makes available to the outside, and which other pieces of code it relies on to work.

Once those things are obvious, your project is already in decent shape.

Not everything needs to be abstracted

This might be the single most important point here.

It's common for developers to introduce abstraction the moment they spot repeated code. But repetition by itself isn't necessarily a problem worth solving.

Say you have:

const user = await fetch("/api/users/42");
const order = await fetch("/api/orders/18");

At some point you might notice that the response-handling logic keeps repeating. That observation could lead you to write:

async function getJson(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }  return response.json();
}

Which lets you simplify to:

const user = await getJson("/api/users/42");
const order = await getJson("/api/orders/18");

That abstraction earns its place—it has a clear job to do.

Now contrast that with building a generic request factory, a request strategy pattern, a repository interface, a transport adapter, a response wrapper, and a full service layer before you've even written a second API call. In that scenario you haven't actually reduced complexity. You've just relocated it into the architecture itself.

This pattern shows up constantly in JavaScript codebases. A developer notices two similar lines and immediately starts designing for a hypothetical future. Good engineering practice often calls for the opposite approach.

Wait until the repetition becomes obvious enough that the right abstraction reveals itself.

Abstractions introduced too early are notoriously hard to remove later, mainly because eventually nobody can say for certain whether they're still doing useful work.

The browser already does a lot for you

Modern JavaScript can feel more complicated than necessary partly because developers lose track of how much the platform itself can do.

You can manipulate the DOM directly. You can attach event listeners. You can issue network requests. You have Web Storage available. You can spin up Web Workers. You can open WebSocket connections. You can manage browser history. You can work with native forms.

All of this is available without reaching for a package for every minor task. For instance:

const button = document.querySelector("#save");
button.addEventListener("click", async () => {
  const response = await fetch("/api/save", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ message: "Hello" })
  });  if (!response.ok) {
    throw new Error("Save failed");
  }
});

That's a fully functional interaction in a real application, with no framework involved.

None of this is an argument against frameworks—they solve real problems. But the underlying platform deserves more recognition than it often gets. Once you understand what the browser and the language already hand you for free, you're in a much better position to decide what a framework should actually be responsible for.

Dependencies come with a cost

Installing a package can save you real time. It can also lock you into a long-term commitment for something that might have taken ten lines of code to write yourself.

Take a small utility like:

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

There's a good chance you don't need an external package for that. But things like authentication, cryptography, intricate date arithmetic, database clients, image processing, and mature validation logic are in an entirely different category.

The lesson here isn't "avoid dependencies at all costs." It's about being aware of what each one actually costs you.

Every dependency brings along:

  • ongoing maintenance
  • security implications
  • version upgrade work
  • documentation to read
  • a tree of transitive dependencies
  • effects on your build process
  • one more abstraction you need to learn

The strongest JavaScript developers aren't necessarily the ones who've memorized the most npm packages. They're the ones who can tell when a package isn't actually needed.

TypeScript adds perceived complexity, but that's a separate discussion

For larger codebases, TypeScript can meaningfully improve the day-to-day development experience. At the same time, it introduces an additional layer to the language.

You end up writing things like:

type User = {
  id: number;
  name: string;
  email: string;
};

Alongside:

function getUser(id: number): Promise<User> {
  // ...
}

This has real value. But it matters to separate complexity that comes from the language from complexity that comes from your tooling setup.

JavaScript itself hasn't become harder simply because a project adds type checking, a linter, a module bundler, a UI framework, an automated test suite, and several build plugins on top of it. What's grown more elaborate is your development environment, and that sophistication can absolutely pay off.

But an elaborate toolchain shouldn't lead you to conclude that the language underneath it is inherently complicated. Often, it's actually quite straightforward.

The real skill is choosing the smallest useful abstraction

A pattern runs through everything discussed so far.

Writing good JavaScript has less to do with mastering every available feature and more to do with picking the leanest tool that gets the job done.

Need to transform the contents of an array? Reach for map.

Need to narrow down a set of values? Reach for filter.

Need to sequence asynchronous work? Reach for async and await.

Need a reusable piece of logic with a clear boundary? Write a function or a module.

Need a collection where keys aren't restricted to strings? Look at Map.

Need to guarantee uniqueness? Look at Set.

Need to safely reach into a possibly missing property? Use optional chaining.

Need a default value only when something is null or undefined? Use ??.

Need a straightforward data container? An object will do.

Need richer behavior and identity tied to your data? A class might fit.

Need to structure a genuinely large application? That's when a framework earns consideration.

Pay attention to that sequence.

You start with the language itself.

Then you look at what the platform already offers.

Then small, purpose-built abstractions.

Then external libraries.

Then frameworks.

Reversing that order is where a lot of unnecessary complexity comes from.

Simplicity is not the absence of engineering

There's a common but misleading assumption about what "simple" code actually means.

Some people equate simplicity with inexperience.

That equation doesn't hold up.

Consider someone new to the craft writing something like this:

function process(data) {
  // everything
}

On the surface it looks minimal, yet it's genuinely hard to maintain once it grows.

A more experienced developer might instead split the same logic into five small modules, each with a deliberate, well-defined boundary.

The result has more moving pieces, but it's actually far easier to reason about.

That's a different, more meaningful kind of simplicity.

The objective isn't to shrink the file count, the dependency list, or the line count.

The objective is to eliminate complexity that isn't earning its place.

That distinction matters more and more as a codebase grows.

A script that's only twenty lines long can still be confusing.

An application with two thousand lines can, surprisingly, be easy to follow.

The deciding factor usually isn't the raw amount of code.

It's whether that code makes its underlying model obvious to the reader.

Stop treating every new feature as mandatory knowledge

JavaScript moves fast, and that pace creates its own kind of stress.

You settle into a way of doing something, and a few months later a different approach is dominating every discussion.

A new framework shows up.

A new runtime starts gaining traction.

A new rendering pattern becomes the trend.

A new piece of syntax ships.

It's easy to feel like you're constantly falling behind.

Chances are, you aren't.

You don't have to absorb every new JavaScript technique the instant someone writes a post about it.

Invest instead in the fundamentals.

Get comfortable with asynchronous programming.

Get comfortable with scope.

Get comfortable with closures.

Get comfortable with objects.

Get comfortable with modules.

Get comfortable with the event loop.

Get comfortable with promises.

Get comfortable with error handling.

Get comfortable with the APIs your browser or runtime exposes.

Get comfortable tracing how data flows through an application.

Only then pick up new tools, and only when an actual problem calls for them.

That approach builds knowledge that lasts.

Familiarity with a specific framework can go stale.

A solid mental model of how programs work generally doesn't.

The best JavaScript code often looks boring

Developers tend to arrive at this realization eventually.

Code that's easiest to maintain rarely impresses anyone on first read.

It doesn't stack five abstractions on top of each other.

It doesn't require a diagram before the first function makes sense.

It doesn't reach for every feature the language added in its latest release.

Often, it just looks plain.

That's not a criticism, it's a strength.

You open the file and the intent is immediately clear:

import { getUser } from "./users.js";
export async function loadProfile(id) {
  const user = await getUser(id);  if (!user) {
    throw new Error("User not found");
  }  return {
    name: user.name,
    email: user.email
  };
}

There's almost nothing to decode.

The function fetches a user.

It checks whether that lookup succeeded.

It hands back the data that's actually needed.

Nothing here is trying to prove how clever its author is.

It simply avoids making the reader do unnecessary work, and that's a far more valuable trait in code.

Modern JavaScript is simpler than our projects

JavaScript has its share of genuine rough edges. Every language does.

But much of the complexity people attribute to "modern JavaScript" doesn't actually originate in the language.

It originates in the layers piled on top of it.

Frameworks.

Build systems.

Dependencies.

Conventions.

Abstractions.

Tooling.

Architectural choices.

Some of these layers are genuinely justified.

Others aren't.

The right question isn't whether a tool counts as modern.

The right question is whether it actually reduces complexity for the specific problem in front of you.

Before reaching for another dependency, check whether the platform already covers it.

Before introducing another abstraction, check whether the duplication you're solving is real.

Before adding another architectural layer, check exactly what failure it's meant to prevent.

Before picking up another framework, check what limitation in your current setup you're actually trying to fix.

And before concluding that JavaScript itself is too complicated, try solving the problem using nothing but the language and the platform it runs on.

You might be surprised by the result.

The language is frequently the simplest part of the equation.

It's developers who keep stacking towers on top of it.

Sometimes the most forward-thinking move you can make is to take a layer away instead of adding one.

The best JavaScript isn't the code that uses the most tools.

It's the code where every tool included has genuinely earned its place.