DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning JavaScript As If You Built It Yourself

If you have ever wanted to make a webpage do something (anything) when you click a button, you have already met JavaScript. It is the only language that ships in every browser on the planet. It runs on servers, on robots, in databases, on watches, on toasters with WiFi.

It is also the language that confuses new developers more than any other, because it grew in layers over thirty years. Some parts feel modern and clean. Some parts feel like a haunted attic. Both parts ship in every browser, and both parts will outlive us.

That is the gap JavaScript fills, and the maze it creates.

What is JavaScript, really

Think of JavaScript as the electricity of the web. HTML gives a page its shape. CSS gives it style. JavaScript makes it move. Plug it in, things happen. It is the only language a browser understands natively, so if you want a website to react to a click, fetch some data, or animate a thing, you are using JavaScript whether you wrote it yourself or it came from a library.

The whole language follows three loud rules:

  • It runs on a single thread. One thing at a time. Long work blocks the page.
  • It is dynamically typed. A variable can hold a number now and a string later. The compiler does not stop you.
  • It is built around objects and functions. Functions are values. Objects are bags of values. That is most of it.

That is the whole vibe.

Let's pretend we are building one

We want a tiny scripting language that runs in the browser, can manipulate the page, and is friendly enough that anyone can pick it up. We will call it JavaScript. Every decision we make will show up in real code, so let's make them on purpose.

For our running example, we are building a tiny mood tracker. It saves a mood, lists moods, talks to a server. Small, but it lets us touch every important corner.

Decision 1: Variables, three flavors and the right default

const mood = "happy";
let count = 0;
var oldStyle = "do not use";
Enter fullscreen mode Exit fullscreen mode

The cheat sheet:

  • const for values that will not be reassigned. Default to this.
  • let for values you actually need to reassign.
  • var is the old keyword. It is function scoped (not block scoped) and gets hoisted in confusing ways. Do not use var in new code.

const does not mean the value is frozen. It only means the binding does not change. const arr = [1, 2, 3]; arr.push(4) is fine. The variable still points at the same array, the array just got a new element.

Decision 2: Two kinds of values, and they behave differently

JavaScript has eight types, and they fall into two camps:

  • Primitives (passed by value, immutable): string, number, boolean, null, undefined, symbol, bigint.
  • Objects (passed by reference): everything else, including arrays and functions.
let a = 1;
let b = a;
b = 2;
// a is still 1

let x = { value: 1 };
let y = x;
y.value = 2;
// x.value is also 2, because x and y point at the same object
Enter fullscreen mode Exit fullscreen mode

This single distinction explains about 30% of beginner bugs. If you mutate an object, every variable pointing at it sees the change.

A small note on the weird two: null is "intentionally nothing" and undefined is "no value yet". They are different types but most code treats them the same way. Use the loose equality just for them: value == null matches both.

Decision 3: Functions are first class

A function is a value. You can store it in a variable, pass it as an argument, return it from another function. This unlocks everything from callbacks to React.

Three ways to write one:

function add(a, b) { return a + b; }            // declaration, hoisted
const sub = function (a, b) { return a - b; }; // expression
const mul = (a, b) => a * b;                    // arrow, the modern default
Enter fullscreen mode Exit fullscreen mode

Two non obvious things matter:

  • Arrow functions do not have their own this. They inherit it from where they were written. This is what you want most of the time, especially in callbacks.
  • Function declarations are hoisted to the top of their scope. You can call them above where they are written. Function expressions and arrows are not hoisted as values.

Closures, the secret superpower

A function "remembers" the variables that were in scope when it was created, even after that scope is gone. That is a closure.

function counter() {
  let count = 0;
  return () => ++count;
}

const next = counter();
next(); // 1
next(); // 2
Enter fullscreen mode Exit fullscreen mode

The inner function still has access to count. Closures are how every JS pattern with private state works under the hood.

Decision 4: Objects, arrays, and the friendly modern syntax

const mood = {
  emoji: "😊",
  label: "happy",
  at:    new Date(),
  tags:  ["calm", "sunny"],
  nested: { weather: "clear" },
};

mood.label;         // "happy"
mood["label"];      // same thing
mood.nested?.weather; // "clear", optional chaining

const { emoji, label = "neutral" } = mood;        // destructuring with default
const { nested: { weather } } = mood;              // nested destructuring

const moodCopy = { ...mood, label: "joyful" };    // shallow copy with override
const moods = [mood, { ...mood, label: "okay" }];

const [first, ...rest] = moods;                    // rest in arrays
function greet(name, ...others) { /* others is an array */ }
Enter fullscreen mode Exit fullscreen mode

The modern bits worth memorizing:

  • ?. is optional chaining. a?.b returns undefined if a is null or undefined instead of throwing.
  • ?? is nullish coalescing. value ?? "default" falls back only when value is null or undefined (unlike ||, which also falls back on 0 and "").
  • Spread ... copies things. Rest ... collects things. Same syntax, opposite meaning, context tells you which.

Decision 5: Equality, and the eternal ===

There are two equality operators. Use one of them and forget the other exists.

1 == "1";   // true. Loose equality coerces types. Confusing.
1 === "1";  // false. Strict equality. The right answer.
Enter fullscreen mode Exit fullscreen mode

Always use === and !==. The only honourable exception is value == null, which is a useful shortcut for "is null or undefined".

Other equality landmines:

NaN === NaN;          // false (yes, really)
Number.isNaN(NaN);    // true (the right way to check)
Object.is(NaN, NaN);  // true
0 === -0;             // true
Object.is(0, -0);     // false
Enter fullscreen mode Exit fullscreen mode

Decision 6: Prototypes, and the classes built on top

JavaScript objects do not have classes underneath. They have a prototype chain. Every object has a hidden link to another object. When you ask for a property, JS walks the chain until it finds it or hits the end.

const cat = { meow() { return "meow"; } };
const mochi = Object.create(cat);
mochi.name = "Mochi";
mochi.meow(); // "meow", inherited
Enter fullscreen mode Exit fullscreen mode

class syntax is just sugar over this:

class Mood {
  constructor(label) {
    this.label = label;
  }
  describe() {
    return `Feeling ${this.label}`;
  }
}

const m = new Mood("calm");
m.describe(); // "Feeling calm"
Enter fullscreen mode Exit fullscreen mode

Modern classes also support private fields with #:

class Counter {
  #count = 0;
  inc() { this.#count++; }
  get value() { return this.#count; }
}
Enter fullscreen mode Exit fullscreen mode

Use classes when you want a clean way to bundle state with methods. Use plain objects when you do not.

Decision 7: this, the most confusing word

this does not mean what it means in most languages. It depends on how the function is called, not where it was defined (except for arrow functions).

const obj = {
  label: "hi",
  say() { console.log(this.label); }
};

obj.say();             // "hi", this is obj
const f = obj.say;
f();                   // undefined or error in strict mode, this is not bound
Enter fullscreen mode Exit fullscreen mode

Three ways to fix it:

  • Use an arrow function (inherits this from outside).
  • Bind explicitly: obj.say.bind(obj).
  • Call with the right form: obj.say() instead of pulling the function out.

The single most useful rule of thumb: inside a method, only use this directly. Inside a callback, use an arrow function or a closed over reference.

Decision 8: Async, the three eras

JavaScript runs on a single thread. To stay responsive, anything that takes time (network, disk, timers) is asynchronous. The way we wrote async code evolved through three eras.

Era 1: callbacks (legacy)

fs.readFile("mood.json", (err, data) => {
  if (err) return handle(err);
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Stack callbacks deep enough and you get "callback hell".

Era 2: Promises

A Promise is an object that represents a future value. It is in one of three states: pending, fulfilled, rejected.

fetch("/api/moods")
  .then((res) => res.json())
  .then((moods) => render(moods))
  .catch((err) => showError(err));
Enter fullscreen mode Exit fullscreen mode

You can chain. You can Promise.all([p1, p2]) to run things in parallel. You can Promise.race, Promise.any, Promise.allSettled.

Era 3: async / await (the modern default)

Syntactic sugar over Promises, but reads top to bottom like normal code.

async function loadMoods() {
  try {
    const res   = await fetch("/api/moods");
    const moods = await res.json();
    render(moods);
  } catch (err) {
    showError(err);
  }
}
Enter fullscreen mode Exit fullscreen mode

Rules:

  • An async function always returns a Promise, even if you return 5.
  • await pauses inside the async function until the Promise settles. It does not block other code.
  • await two things in sequence runs them in sequence. Use Promise.all to run them in parallel.
// sequential, slow
const a = await fetchA();
const b = await fetchB();

// parallel, fast
const [a, b] = await Promise.all([fetchA(), fetchB()]);
Enter fullscreen mode Exit fullscreen mode

Decision 9: The event loop, the actual brain

Why does any of this work on a single thread? The event loop.

Roughly:

  1. JS runs the code on the call stack until it is empty.
  2. Async work (timers, network, I/O) is handed off to the host (browser or Node).
  3. When the host is done, it puts a callback into a task queue.
  4. The event loop pulls from the queue when the stack is empty and runs it.
  5. Microtasks (Promise callbacks, queueMicrotask) run before the next task. They get priority.

Three consequences worth knowing:

  • A blocking loop (while (true) {}) freezes everything, including the page.
  • setTimeout(fn, 0) does not run "immediately". It runs after the current task, possibly after microtasks.
  • Awaiting a Promise that resolves with a Promise is fine. JS unwraps them for you.

Decision 10: Modules, the modern way to organize code

Modern JavaScript uses ES Modules (ESM). Every file is its own scope. You import what you need and export what you offer.

// mood.js
export function describe(m) { return `Feeling ${m.label}`; }
export const DEFAULT_MOOD = { label: "neutral" };
export default function MoodCard(props) { /* ... */ }

// app.js
import MoodCard, { describe, DEFAULT_MOOD } from "./mood.js";
Enter fullscreen mode Exit fullscreen mode

A few rules:

  • One file, one module. Imports are static, the bundler can tree shake unused exports.
  • Top level await is allowed in modules. Useful for config loading.
  • Dynamic import when you only sometimes need a module: const m = await import("./heavy.js").

The old format you will still see in legacy Node code is CommonJS: const x = require("x"); module.exports = .... New code defaults to ESM.

Decision 11: Iterators, generators, and the for...of party

Anything that follows the iterator protocol can be looped.

for (const mood of moods) { /* arrays, sets, maps, strings */ }
for (const [key, value] of Object.entries(obj)) { /* objects */ }
for (const key in obj) { /* the keys, including prototype keys, prefer Object.keys */ }
Enter fullscreen mode Exit fullscreen mode

Generators (functions with function*) let you yield values lazily, perfect for streams or infinite sequences.

function* range(start, end) {
  for (let i = start; i < end; i++) yield i;
}

for (const i of range(0, 5)) { /* 0, 1, 2, 3, 4 */ }
Enter fullscreen mode Exit fullscreen mode

Most of the time you will not write generators. Most of the time you will use the iterator protocol indirectly through for...of, spread, and array methods.

Decision 12: Useful collections beyond array and object

  • Map for any kind of key (objects too), keeps insertion order, has .size.
  • Set for unique values. [...new Set(arr)] deduplicates an array.
  • WeakMap / WeakSet for memory friendly key references that allow garbage collection.
const seen = new Set();
moods.filter((m) => !seen.has(m.id) && seen.add(m.id));
Enter fullscreen mode Exit fullscreen mode

When the keys are not strings or you need predictable iteration order, Map beats a plain object.

Decision 13: Errors, and how to handle them

try {
  riskyThing();
} catch (err) {
  if (err instanceof RangeError) { /* ... */ }
  if (err.code === "ENOENT")     { /* ... */ }
  throw err; // rethrow if you cannot handle
} finally {
  cleanup();
}
Enter fullscreen mode Exit fullscreen mode

Modern best practices:

  • Throw Error objects, not strings. They carry a stack trace.
  • Subclass Error for app specific kinds: class NotFoundError extends Error {}.
  • Use Error cause: new Error("could not load", { cause: original }).
  • In async code, await inside try, otherwise the catch will not see the rejection.

Decision 14: A few modern goodies

  • Optional chaining (a?.b) and nullish coalescing (a ?? b).
  • Logical assignment (a ||= b, a ??= b, a &&= b).
  • structuredClone(obj) for a deep clone built into the platform. No more JSON.parse(JSON.stringify(...)).
  • Object.groupBy(arr, fn) for grouping arrays by a key.
  • Array.prototype.toSorted / toReversed / toSpliced / with for non mutating versions of the classics.
  • Promise.withResolvers() for cleaner manual Promise creation.
  • AbortController for cancelling fetches and other async work.
  • Top level await in modules.
  • BigInt for integers larger than Number.MAX_SAFE_INTEGER (about 9 quadrillion).

Decision 15: Tooling that you should know exists

In 2026, almost no real JavaScript project ships hand written, untranspiled, no bundler code. The modern stack:

  • Node.js runs JS on the server. Recent versions ship with a fetch, a test runner, and ESM as default.
  • Bun and Deno are alternative runtimes. Bun is fast and has a built in bundler. Deno has built in TypeScript and security permissions.
  • Vite is the bundler of choice for frontend apps. esbuild and SWC power most build tools under the hood.
  • TypeScript adds a static type layer over JavaScript. Almost every team uses it for non trivial apps.
  • Biome and ESLint lint your code. Prettier formats it.
  • Vitest is the modern test runner. Playwright does end to end tests.

You do not need to learn all of them on day one. Learn that they exist so you recognize the names.

A peek under the hood

What really happens when a browser runs JavaScript:

  1. The HTML parser hits a <script> tag and hands the source to the JS engine (V8 in Chrome and Node, JavaScriptCore in Safari, SpiderMonkey in Firefox).
  2. The engine parses the source into bytecode.
  3. The bytecode runs in an interpreter while a JIT compiler watches for hot functions and optimizes them into machine code.
  4. The runtime gives the engine access to host APIs (DOM, fetch, timers in the browser; fs, net in Node).
  5. The event loop coordinates async work.
  6. Memory is managed by a garbage collector that periodically frees objects nothing references anymore.

Two consequences for senior level work:

  • Functions get faster the more they run (warm up, then JIT). This is why microbenchmarks lie.
  • Holding references to big objects in closures or long lived structures leaks memory. WeakMap and breaking references explicitly help.

Tiny tips that will save you later

  • Default to const. Use let only when you actually reassign. Never use var.
  • Use === always. Two characters of safety for free.
  • Treat data as immutable when you can. Spread, slice, toSorted. Mutating shared objects is a footgun.
  • Use arrow functions for callbacks, regular methods for object methods.
  • Use async/await. Wrap parallel work in Promise.all.
  • Use ESM (import/export). Avoid CJS in new code.
  • structuredClone for deep copies. No more JSON tricks.
  • Use Map and Set when keys are not strings or uniqueness matters.
  • Throw real Error objects with cause.
  • Add TypeScript as soon as the project has more than two files. Future you will be grateful.
  • Run node --inspect or DevTools when debugging. console.log is not the only tool.

Wrapping up

So that is the whole story. We needed a small, friendly language for the browser, and we ended up with one that runs everywhere, with three decades of backwards compatible features piled on top of each other. We have variables (const, let), values (primitives and objects), functions as first class citizens, prototypes hidden under classes, asynchronous code that evolved from callbacks to async/await, and an event loop quietly orchestrating everything.

We learned which corners are sharp (==, var, this, NaN, mutation), which features are modern and beautiful (?., ??, modules, generators, structuredClone), and which tools sit around the language (Node, Bun, Vite, TypeScript) to make real apps shippable.

Once that map is in your head, every blog post, every framework, every "weird JS quirk" tweet starts to feel familiar. JavaScript stops being a haunted attic and starts being the most flexible, most ubiquitous tool in your belt.

Happy scripting, and may your this always be what you expect.

Top comments (0)