Tural Hajiyev

Frontend Engineer

How to Rewrite Array Methods — map, filter, reduce, flatMap From Scratch

Sep 2, 2026

How to Rewrite Array Methods — map, filter, reduce, flatMap From Scratch

While preparing for interviews, I found out that one of the "hot-potato" topics of javascript interviews is, implementing map, reduce, filter flatMap methods from scratch. First it sounds easy to do, as JS developer we almost everyday using it but from the first line I got stuck. I even forgot how to add a method to Array, because I don't remember when last time I used prototype. More or less, this is what I relearned while getting it right.

map method

Map function of Array has 2 parameters - callbackFn and thisArg.

Callback function is easy, as we always use it. For example,

const items = [1,2,3,4,5] const newItems = items.map(item => item * 2)

It's the must-have parameter, but I don't remember any time I used thisArg.

thisArg is used as this when executing callbackFn. For example, let's say we have following code:

const calculator = { multiplier: 10, multiply(x) { return x * this.multiplier; } }; const numbers = [1, 2, 3]; const result = numbers.map(calculator.multiply);

I would expect this.multiplier will be undefined, and the result will be [NaN, NaN, NaN]. But if you ask me how to solve it, I couldn't answer. That's when thisArg is useful actually. If we pass calculator as thisArg to map function, the result will change:

// previous object definition const result2 = numbers.map(calculator.multiply, calculator);

This time, the result will be [10,20,30] instead of multiple NaN.

thisArg is ready, now it's time to think about callback function. It also receives multiple arguments: element, index, array

Now we have the full setup to create our own map function.

First we need to define the function. we can do that by adding function to Array.prototype:

Array.prototype.myMap = function(callback, thisArg) {}

So it's the background to work with the function. Inside, we can define empty array, we can iterate over initial array, and we can call the callback function we have with call function. Why call function? It will help us to define the environment to call the callback. First argument of call function is this environment, and the remaining ones are arguments we want to pass to the function.

let array = []; for (let i = 0; i < this.length; i++) { array.push(callback.call(thisArg, this[i], i, this)) }

Here, thisArg is the environment we want to the call the function. (for example, calculator object). this[i] is the element we access currently. i is the index, and this is the original array.

If we combine it with the previous code, the result will be:

Array.prototype.myMap = function (callback, thisArg) { let arr = []; for (let i = 0; i < this.length; i++) { arr.push(callback.call(thisArg, this[i], i, this)); } return arr; };

It will work. Partially. While working on it, I learned something new related to arrays. map function should return the array with the same length. Array can have multiple values like number, string, undefined, null, but it can also have holes. For example, following array is valid:

const arrayWithHole = [1, , 3]

If we call this array with our method for example to multiply with 2, the hole will become NaN. If we don't do anything, it will be undefined. Ideally, we need to keep it as a hole, because we didn't set any value. For that, we need to catch if it's the hole, and skip calling callback function. It can be done by having in operator:

if (i in this) // call callback // move to the next element

The most final code will be:

Array.prototype.myMap = function(callback, thisArg) { if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`); const result = []; for (let i = 0; i < this.length; i++) { if (i in this) { // skip holes in sparse arrays result.push(callback.call(thisArg, this[i], i, this)); } } return result; };

filter method

Filter function has the same shape as map. The main difference is we conditionally pushing to the result array, which means length of initial array and output array can be different.

Array.prototype.myFilter = function(callback, thisArg) { if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`); const result = []; for (let i = 0; i < this.length; i++) { if (i in this && callback.call(thisArg, this[i], i, this)) { result.push(this[i]); } } return result; };

Here, if it's not a hole and callback.call(...) returns true, we add the element to the result array. Otherwise we just skip it.

reduce function

Here is the definition of reduce method in MDN:

The reduce() method of Array instances executes a user-supplied "reducer" callback function on each element of the array, in order, passing in the return value from the calculation on the preceding element. The final result of running the reducer across all elements of the array is a single value.

It's coming with 2 arguments, first one is callbackFn and the second one is initialValue we want to start with.

Also callbackFn is coming with accumulator (The value resulting from the previous call to callbackFn), currentValue (The value of the current element), currentIndex (The index position of currentValue in the array) and array (The array reduce() was called upon)

So main idea is, first we check if initialValue is provided or not. Based on that, we find initial value, and initial index. If provided it's easy, otherwise we need to find first valid element to start to the accumulator.

let acc; let startIndex; if (arguments.length >= 2) { acc = initialValue; startIndex = 0; } else { // find first non-hole element to use as initial acc let i = 0; while (i < this.length && !(i in this)) i++; acc = this[i]; startIndex = i + 1; }

After having valid accumulator and startingIndex, we can iterate over array, and call callback function:

for (let i = startIndex; i < this.length; i++) { if (i in this) { acc = callback(acc, this[i], i, this); } }

and the final code will be:

Array.prototype.myReduce = function(callback, initialValue) { if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`); if (this.length === 0 && arguments.length < 2) { throw new TypeError('Reduce of empty array with no initial value'); } let acc; let startIndex; if (arguments.length >= 2) { acc = initialValue; startIndex = 0; } else { // find first non-hole element to use as initial acc let i = 0; while (i < this.length && !(i in this)) i++; acc = this[i]; startIndex = i + 1; } for (let i = startIndex; i < this.length; i++) { if (i in this) { acc = callback(acc, this[i], i, this); } } return acc; };

flatMap method

flatMap is map followed by a flatten of depth 1. We can use the above code slightly modified, just add a narrowing for Array type. If it's an array, we need to destruct it, otherwise we can push it as it's:

Array.prototype.myFlatMap = function(callback, thisArg) { const result = []; for (let i = 0; i < this.length; i++) { if (i in this) { const mapped = callback.call(thisArg, this[i], i, this); if (Array.isArray(mapped)) { result.push(...mapped); } else { result.push(mapped); } } } return result; };
Jun 18, 2026

React Project Folder Structure — Patterns for Scalable Frontend Apps

When React was first released, it became popular with the type-based folder structure. Later, as bigger projects were created, the feature-based folder structure started to become popular. Both structures have their own benefits, and making the right choice fully depends on the project's scope and needs.

While writing this article, I also wanted to include Atomic Design, but then I realized it's not really a folder structure. It's a great example of how UI libraries can be organized, but the biggest blind spot of Atomic Design is that it wasn't designed for data or logic. It says nothing about where API calls, hooks, or business logic go. Atomic Design was created purely for UI composition, not for handling data or application logic. It can be used together with all folder structures, but alone it can't help to create a full frontend application.

To make things easy to understand, I will mention an ERP application with contacts, trade, finance, and hr modules. This application will be mentioned in multiple places.

Type-based folder structure

Here's a common example of what a type-based folder structure looks like:

src/ components/ hooks/ services/ utils/ pages/

Here, the idea is to have a folder for each type (component, hook, etc.) and store them together. This structure has its own advantages and disadvantages.

Advantages

This structure has been around since the early days of React and remains familiar to most developers today. Many small and mid sized projects still use it, making it easy for new team members to get started without much explanation. Since it's so common, you don't need to spend time debating folder structure upfront—just group files by their "type" (component, hook, etc.) and go.

Initially, all applications start simple, so it's a smart move to start with this structure and move to a more complex one later, instead of struggling with a complex folder structure when you have only a few components and hooks.

Disadvantages

Because the relationships between files aren't clear in this structure (for example, a component in components might use another component or a custom hook from somewhere else), it becomes pretty difficult to make changes to a single feature.

Imagine the company decides the hr module should become its own standalone application. Trying to delete that module or move it to a new GitHub repo quickly turns into a headache, since there's no clear way to know which files belong to HR, what its dependencies are, or where its boundaries end. Sure, you could try to prefix everything with HR (HRLandingPage.tsx, HRPeople.tsx) to keep things together, but once you break this naming rule anywhere, or if you rely on a shared function (like a usePeople.tsx hook), it all gets messy and hard to untangle.

Also, it's worth mentioning that if you decide to define boundaries by using prefixes like HR, Finance, or Contacts, after a while you will see really ugly file names in the repo. HRPayrollTableWithEditModal.tsx is not a good name, and you will not be happy to see it every time.

Additionally, as files for a single feature end up scattered between different folders, you often have to jump around the codebase to trace how everything connects. For people working on the project daily, this back-and-forth becomes second nature.

But for new team members, or if you step away from the project and return later, it's much harder to piece together how components, hooks, and other files relate. In /components with over 100 files or in the /hooks folder with 25 files, it's easy to get lost, even if you knew the project well before.

Feature-based folder structure

src/ features/ hr/ components/ hooks/ api/ finance/ components/ hooks/ api/ shared/ components/ hooks/ api/

With this structure, each feature has its own folder to live in. Additionally, there is a shared folder to store common code and services used across multiple modules.

Firstly, this structure makes it easy to understand the business domain and manage code, because all files related to a feature are grouped together. If you hire a new team member and they are required to work in the finance module, they don't need to check the hr module. (It's not always true, but it works in certain cases. My point is it's easier to work within a feature's folder without jumping around the project.) Also, this benefit makes it easier to delete a feature or promote it to a new application, which was a nightmare with a type-based folder structure.

Secondly, as the project grows, new features don't clutter up unrelated areas. If you start to work on an "Orders" module, it will be fully isolated, and you won't need to worry about your code mixing with HR or Finance.

Finally, shared code lives in a clear location, and features are less likely to depend on each other's internals. It's not 100% true, but works in most cases. I will mention in the next paragraph that sometimes one module needs to consume another module's functionality, and that's when things can get messy.

Disadvantages

As mentioned in the previous paragraph, sometimes features can depend on each other's internals. Imagine there is a trade module, and after you create a trade invoice, you need to call a function from the finance module to make the payment. This logic breaks the motivation behind the feature-based structure, as one feature depends on another one, and they are not isolated anymore. You can think to move this functionality to the shared folder, but then a functionality related to finance will sit in the shared folder, instead of finance. I would say it's one of the main constraints of the feature-based structure in large scale applications.

Secondly, if not managed carefully, some logic might get duplicated across feature folders. Especially at rush times or with short deadlines, when code reviews get skipped, you can easily find some functionality (utils/disableScroll.tsx) defined in two modules instead of the shared folder.

Finally, if you have work which covers all components or all hooks, this structure is less predictable by type. It means if you need to check all components, you need to visit each feature's components folder instead of having a single components folder.

Feature-based folder structure is ideal for most medium sized projects. For large projects, sometimes it raises questions as mentioned before, like should features only use code in the shared folder, or is it okay for one feature to directly import code from another? What if the trade module has a small feature that after a trade operation, it's required to call a finance operation? Without clear rules, you can accidentally create tangled dependencies between features and suddenly lose the main benefit this structure brings. In addition, over time, a single feature folder can become bloated as new components, hooks, and logic are added. It's not always obvious when a feature has grown too large and should be split up into smaller sub-features or reorganized for clarity.

What about Feature-Sliced Design (FSD)?

You might have heard about Feature-Sliced Design (FSD). It's a relatively new, layered architectural approach that's gaining popularity for organizing large-scale frontends. FSD introduces concepts like "layers" (app, pages, widgets, features, entities, shared) and "slices" for domain grouping, and enforces clear, strict boundaries between them.

I haven't used Feature-Sliced Design (FSD) in a production project myself yet. I'm still learning about it and exploring how it works.

Apr 24, 2026

GraphQL vs REST API — Which one to choose?

A REST API is like a restaurant with a set of fixed combo menus. You can order Menu A, which comes with a burger, fries, and a cola — but you can't swap the fries for a salad. GraphQL, on the other hand, sells you everything à la carte, so you build your own plate exactly the way you want it.

Say you're building an ERP application and it uses a REST API to make requests for different modules. Different API endpoints help get data related to relations, trade operations, and finance data. For example, the backend team sets up /relations and wires up each endpoint:

GET /api/v1/relations/ GET /api/v1/relations/[relation-id] POST /api/v1/relations PUT /api/v1/relations DELETE /api/v1/relations

In REST APIs, the server shapes every response. You get fixed endpoints like (/relations/1, /relations/1/trade-operations), and each returns a defined structure.

Over-Fetching and Needing Backend Changes

The GET /api/v1/relations endpoint returns a large dataset for each relation. For example, the data might look like this:

[ { "id": "1725", "fullName": "Michael Scott", "companyName": "Dunder Mifflin", "date": "2024-01-01", "address": "1725 Slough Avenue, Scranton, PA", "phone": "555-2323", "notes": "World's Best Boss" // ...many other fields }, { "id": "1726", "fullName": "Pam Beesly", "companyName": "Dunder Mifflin", "date": "2024-01-10", "address": "1727 Slough Avenue, Scranton, PA", "phone": "555-4545", "notes": "Receptionist" // ...many other fields } ]

For the relations page, we need this data. A new task comes in, and it's required to show relations on the dashboard in compact form. For the dashboard, you only need fullName, companyName, and date. Calling GET /api/v1/relations brings back all fields—more than you want. That's over-fetching.

With REST API, there are two common ways to improve it. Either a new endpoint can be introduced with the compact data,

GET /api/v1/relations/mini-dashboard

or filters can be added, which can be used to get only the necessary fields:

GET /api/v1/relations?fields=fullName,companyName,date

Both implementations require backend changes. It's required to add a new endpoint, or update the existing one. Over time, endpoints may drift from what the frontend needs.

Over-fetching isn't just a read concern. If you POST a new relation and want only the id and fullName back, you still might get the entire object:

{ "id": "1725", "fullName": "Michael Scott", "companyName": "Dunder Mifflin", "date": "2024-01-01", "address": "1725 Slough Avenue, Scranton, PA", "phone": "555-2323", "notes": "World's Best Boss" // many other fields }

Getting a bunch of unnecessary data back, with no way to avoid it, is called over-fetching.

GraphQL uses a different model. There's one endpoint (/graphql). The frontend picks the fields it wants, regardless of scenario.

Instead of /api/v1/relations always returning the same thing, a GraphQL query can be as specific as the client likes:

query { relations { fullName companyName date } }

GraphQL lets the client decide which fields to fetch—from a single endpoint (/graphql).

  1. No over-fetching anymore, and frontend gets the fields it asks for.
  2. No backend change needed for small tweaks. Need to add a date field? Only need to include it in the query (as long as that field's in the schema).

Multiple Requests and Chatty APIs

REST usually means making several calls to build a single page. A dashboard, for example, might need:

  • GET /api/v1/relations
  • GET /api/v1/relations/:id/trade-operations
  • GET /api/v1/relations/:id/financial-operations

Each call adds to network overhead. If the data is nested or related, you have to keep stacking requests to get a complete view.

With GraphQL, the client can ask for everything at once—including nested and related records:

query DashboardRelations { relations { id name tradeOperations { id date financialOperations { id amount } } } }

It's easy to avoid over-fetching, and there's no need to merge and manage results from multiple REST calls.

Type Safety

REST returns generic JSON. On the frontend, developers can write TypeScript interfaces to match the responses. If the backend changes a field (or its name), types can get out of sync easily. It's easy to miss these changes, and your app and API quickly drift apart.

You often handcraft interfaces:

interface FinancialOperation { id: number; amount: number; } interface TradeOperation { id: number; date: string; financialOperations: FinancialOperation[]; } interface Relation { id: number; name: string; tradeOperations: TradeOperation[]; }

If the backend renames a field (say, amount becomes value), in REST you need to find and update every related TypeScript interface throughout your app. As your app grows, this gets more painful.

With GraphQL, the schema is typed, and you can generate your TypeScript types straight from it. When the schema changes, just rerun codegen, and your frontend types stay in sync. No guesswork, no more hand-maintained interfaces.

Cache Sync

With REST, you typically have to refetch big lists (inefficient), or manually patch cached entries (fragile and messy):

const mutation = useMutation(updatePost, { onSuccess: () => { queryClient.invalidateQueries(["posts"]); // refetch all posts }, });

With a normalized GraphQL client like Apollo or Relay, data is cached by unique ID. When an object is updated, all parts of the UI using that data are updated automatically. No need to refetch lists or manually update the cache. (Note that this caching behavior comes from the client library, not GraphQL itself.)

When To Use GraphQL or REST

It depends. If the data is deeply nested, and you have multiple clients with different data needs, that's when GraphQL shines. Or, if there are over-fetching pains in many places, GraphQL is a great tool to use. One schema acts as the source of truth. Frontend teams can move faster without constantly asking for changes from backend tweaks.

If the API is simple—just a few resources, basic CRUD, and maybe file uploads, REST is often best. Simple is good for files, webhooks, and integrations that don't need deep flexibility.

In practice, teams mix both: GraphQL for the main app UI, REST for jobs like file uploads, authentication, webhook handlers, or talking to legacy systems. The setup isn't always pretty, but it works and scales as needs evolve.

Mar 4, 2026

Monolith, Monorepo, or Microfrontends?

Discussions about frontend architecture often include suggestions to use microfrontends. But when I look for strong reasons behind these recommendations, they rarely hold up for most teams.

Here's my core takeaway:

Microfrontends are designed to let completely independent teams deploy their features separately from each other. This only makes sense if you are at a large company with distinct, siloed teams. Most organizations don't need this level of separation. The popularity of microfrontends has more to do with the appeal of new trends than with solving everyday problems.

A lot of confusion comes from treating these ideas as a single decision, when in reality they're separate:

  1. Where does your code live? One repo or multiple (monorepo vs polyrepo)?
  2. How is code structured inside the repo? Is everything together, or split into packages (single package vs workspaces)?
  3. How does it run in the browser? Is there one deployed build, or several independently deployed builds combined at runtime (monolith vs microfrontend)?

You can have a monorepo and still ship a single monolith. You can break a monorepo into workspace packages while keeping one deployable artifact. Microfrontends only address the third point, which is about how your code ships and runs—not where the source lives.

The main promise of microfrontends is this: Teams can deploy to production independently.

That's basically it. In practice, it means scenarios like:

  • Team A can release code on Tuesday afternoon, Team B can release Thursday morning. One team's work doesn't block the other. A buggy deployment in one module doesn't crash other unrelated modules on the same page.
  • Teams can set their own testing or release cadence, as long as everyone agrees on integration boundaries.
  • Ownership lines are clearer and each team handles its own CI/CD, on-call, and maintenance.

Q: If my code is in different repos or npm packages, do I have microfrontends?

A: Not exactly. If you publish to npm and your shell app installs other packages from there, you're still combining everything at build time, not at runtime. You don't get real independent deployment. Even with code in different repos, deployments are still tied together and require coordination. Here is the difference visually:

Diagram: NPM package vs Microfrontend deployment

True independence comes with challenges.

One big challenge is loading React and React DOM as true singletons across all modules. Version mismatches won't always throw build errors; problems may only show up at runtime in production. Each module needs its own build pipeline, deployment environment, and a robust system for sharing and versioning the manifest that lets the shell discover them.

Local development also gets trickier. You may have to juggle multiple dev servers, or mock modules you aren't actively working on, just to get things running.

Tracing problems across runtime boundaries is more complex than tracing through regular function calls. And optimizing performance is less straightforward since the bundler can't see or deduplicate all code in one place.

Q: Do your HR, Finance, and Trade teams actually need to deploy independently whenever they want?

A: If each team has its own roadmap, works in isolation, and release coordination is a real, recurring pain, then microfrontends can add value. But if there's really just one team, or everyone ships together by default, microfrontends introduce a lot of complexity for almost no gain.

Oct 5, 2025

Fancy TypeScript Things I Use Every Day (But Didn't Know Their Names)

Static vs dynamic typing

JavaScript is dynamically typed, TypeScript is statically typed. Day to day, it means, in JavaScript, a variable's type is a runtime fact. The engine doesn't know or care what user is until the code actually runs and it looks at the value sitting in memory.

var user = { name: "Tural" }; user = 42; // Even if there is a police, it's totally legal

TypeScript moves that check earlier, to the moment you write the code, not the moment the engine runs it. The compiler builds a model of what every variable should be, and if you try to violate that model, it complains before your code ever executes.

var user = { name: "Dana" }; user = 42; // Type 'number' is not assignable to type '{name: string}'

These types do not exist at runtime. They are erased during compilation, as we generate .js code from .ts code to run the app. The browser or Node environment has never seen a type or an interface in its life. It can't save us from a bad API response or a null value that we defined as number, because TypeScript is reasoning about the code without running it.

Type narrowing

Narrowing is TypeScript watching your control flow and shrinking a type based on what you have already checked. It's the reason this doesn't work:

function printLength(value: string | number) { console.log(value.toFixed(2)); }

and this works:

function printLength(value: string | number) { if (typeof value === "string") { console.log(value.length); // TS knows it's a string here } else { console.log(value.toFixed(2)); // and a number here } }

In the if block, we don't need to say TypeScript value is a string. It figured that out on its own from the typeof check. That's narrowing. It works with typeof, instanceof, in, truthiness checks, equality checks against literals.

Narrowing lets TypeScript discriminate between variants of a union:

type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number }; function area(shape: Shape) { if (shape.kind === "circle") { return Math.PI * shape.radius ** 2; // radius exists, guaranteed } return shape.side ** 2; // side exists here instead }

That kind field is doing all the work. Because each variant has its own literal type for kind, checking it lets TypeScript figure out exactly which shape of object you are holding at each branch. In the TypeScript world, it is also known as a tagged union.

Control flow analysis

TypeScript doesn't just look at each line in isolation, it keeps track of how your code flows, updating what it knows about each variable along the way. This is called control flow analysis.

Because of that, TypeScript can spot when certain conditions are no longer possible, which lets you write safer, more expressive code. For example:

function getUser(id: string | null) { if (!id) { return null; } // TypeScript knows id can't be null here return fetchUser(id); }

Here, since we return early when id is null, TypeScript understands that by the time we reach fetchUser(id), id must be a string, not null.

But control flow analysis really shines when dealing with union types and exhaustive checks. If you forget to handle all possible cases in a switch, TypeScript can catch it at compile time:

type Status = "loading" | "success" | "error"; function getMessage(status: Status) { switch (status) { case "loading": return "Loading..."; case "success": return "It worked!"; // What if we forget "error"? default: // TypeScript can flag this as an unhandled case. // To make this safer: const _exhaustiveCheck: never = status; return _exhaustiveCheck; } }

If you later add another variant (like "idle") to Status, TypeScript will warn you anywhere the type needs to be updated.

Contextual typing

This is something all of us using every day, just don't know its name. Contextual typing is when TypeScript infers a type for something based on where it's being used, rather than from the expression itself.

window.addEventListener("click", (event) => { console.log(event.button); // event is inferred as MouseEvent, not `any` });

In this example, we never define type for event. TypeScript looked at the signature of addEventListener, saw that click event maps to a MouseEvent handler, and typed the callback's parameter accordingly. Same story with array methods:

const prices = [10, 25, 40]; const doubled = prices.map((price) => price * 2); // price: number, inferred

price is contextually typed as number, because TypeScript knows that prices is number[] andmap's callback parameter matches the array's element type.

Apr 10, 2025

How a Web Page Loads HTML, CSS, and JS Content

HTML document is the first thing the browser needs before anything can be shown. It sends a request to the server, and the server responds with an HTML file. This file is the skeleton of everything. Without it, there's nothing to parse, style, or render. Every other resource (CSS, JavaScript, images, fonts) is discovered through this HTML, so it has to arrive first.

Once the HTML starts arriving, the browser doesn't wait for the whole file and it begins parsing immediately, streaming through the markup from top to bottom, converting tags into a tree of nodes called the DOM (Document Object Model).

This top-to-bottom order matters a lot. The position of a <link> or <script> tag in the document directly affects when and how the browser handles it.

When the parser encounters a CSS file, it doesn't stop building the DOM. CSS doesn't block HTML parsing.

CSS file only blocks rendering. The browser won't paint anything to the screen until it knows the styles, because painting unstyled content and then restyling it would cause an ugly flash and wasted work. The CSS file is fetched, parsed into the CSSOM (CSS Object Model), and once both the DOM and CSSOM are ready, they combine into the render tree.

CSS doesn’t block the HTML parser, but it does block rendering.

Hitting a <script> Tag

There are 2 pathways here:

  • Inline <script>: already in the HTML, no network request needed.
  • External <script src="...">: the browser has to fetch it.

Script tags block the HTML parser by default. The browser's HTML parser reads your page top to bottom, building the DOM as it goes. When it hits a <script> tag with no attributes, it has to:

  1. Stop parsing HTML.
  2. Fetch the script (if external).
  3. Run the JS engine on it, fully, top to bottom.
  4. Only then resume parsing the rest of the HTML.

Always put your <script> tags at the bottom of <body> — it's classic advice everyone has heard at least once. Let the page's visible content parse first; otherwise, all content will be blocked.

Plain <script>: blocks HTML parsing entirely while it fetches and runs.

async and defer exist specifically to avoid this blocking.

async fetches in parallel with HTML parsing, but runs the moment it's ready. It pauses parsing whenever that happens, in whatever order scripts finish downloading.

defer fetches in parallel too, but always waits until the HTML document is fully parsed, and runs multiple deferred scripts in their original order.

Once the DOM (structure) and CSSOM (styles) both exist, the browser merges them into the render tree.

The browser then calculates the exact size and position of every visible element on the page, which is called layout or reflow. It figures out, in pixels, where the box for each element sits relative to the viewport and its neighbors.

After layout (reflow) but before painting, the browser runs "layout effects" like React's useLayoutEffect. This lets you measure or change the DOM right before anything is shown to the user.

With the positioning and any layout effects settled, the browser proceeds to the paint phase by filling in all the pixels: text, colors, borders, shadows, and images.

After the browser paints the screen, React runs useEffect. This is good for things like data fetching or logging, since it happens after the user already sees the changes.

Sep 19, 1999

Various topics to prepare for the interview

Types & Values

Data types

JavaScript has 7 primitives: string, number, bigint, boolean, undefined, symbol, null. Everything else is an object (arrays, functions, dates, maps...).

  • Primitives are immutable. "abc".toUpperCase() returns a new string; it never changes the original.
  • Primitives still have methods because JS temporarily wraps them in an object (autoboxing): "abc".length.

typeof null === "object" is a historic bug.

typeof function(){} === "function"

Use Array.isArray() for arrays.

undefined vs null

undefined means a value hasn't been assigned. The language produces it automatically: a declared variable with no value, a missing object property, a function parameter that wasn't passed, or a function that doesn't return anything.

null means "intentionally empty." The language never sets it on its own; a programmer assigns it on purpose to say "there's deliberately nothing here," like clearing a reference or marking a missing result.

Type conversion vs coercion

Both mean changing a value from one type to another. The difference is who does it:

  • Type conversion (explicit type casting) is when you deliberately change the type.
  • Type coercion (implicit type casting) is when JavaScript changes it automatically behind the scenes, usually because an operator needs a certain type.
// conversion (explicit) Number("42"); // 42 String(1); // "1" Boolean(""); // false // coercion (implicit) "5" + 1; // "51" (+ prefers strings) "5" - 1; // 4 (- only works with numbers) if ("hello") {} // string → true

Falsy values: false, 0, -0, 0n, "", null, undefined, NaN. Everything else is truthy, including [], {} and "0".

== vs === vs Object.is

// == coerces, === doesn't 1 == "1"; // true ("1" → 1) 1 === "1"; // false 0 == false; // true (false → 0) "" == 0; // true ("" → 0) "0" == false; // true (both → 0) "" == "0"; // false (both strings, no coercion; no transitivity!) null == undefined; // true null == 0; // false (null only loosely equals undefined) [1] == 1; // true ([1] → "1" → 1) // === vs Object.is NaN === NaN; // false Object.is(NaN, NaN); // true +0 === -0; // true Object.is(+0, -0); // false // All three compare objects by reference ({}) === ({}); // false Object.is({}, {}); // false const o = {}; o === o; // true

Value vs Reference

Primitives are copied by value, and objects and arrays are copied by reference.

const a = { n: 1 }; const b = a; // same object, two references b.n = 2; a.n; // 2

Spread ({...obj}) and Object.assign make shallow copies, so nested objects are still shared. For a deep copy use structuredClone() (not JSON.parse(JSON.stringify(...)), which drops dates, undefined, and Maps). Object.freeze is shallow too. Immutable update patterns are what make React state and memoization work: React compares references, not contents.

Variable naming rules

let userName; // ok let _count; // ok let $price; // ok let item2; // ok let 2item; // SyntaxError let user-name; // SyntaxError (hyphen means minus) let user name; // SyntaxError (no spaces)

Some of the reserved words: let, const, class, return, if, function, new, this, typeof and so on.

Default naming conventions:

let firstName = "Ada"; // camelCase for variables and functions function getUser() {} class UserAccount {} // PascalCase for classes const MAX_RETRIES = 3; // UPPER_SNAKE_CASE for fixed config values let isLoggedIn = true; // booleans read like yes/no questions let hasAccess = false;

Variables & Scope

var vs let vs const

  • var is function-scoped, while let and const are block-scoped (limited to the nearest {}).
  • var allows redeclaring the same name in the same scope (a common source of bugs). let and const throw a SyntaxError.
  • const can't be reassigned, but the object it points to can still be mutated.
  • All three are hoisted, but differently. var is initialized as undefined, so you can access it before the declaration without an error. let and const sit in a "temporal dead zone" until the declaration line runs, so early access throws.
  • var at the top level creates a property on window; let/const don't.

Explain this code:

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3, 3, 3 — one shared i for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0, 1, 2 — new i per iteration

A function reads a variable when it runs, not when it's created. setTimeout means "run this function later." Even with no delay, JavaScript first finishes all the code it's currently running, and only then runs the scheduled functions. As "var" is function scoped, there is only one "i" variable. When the loop is done and the scheduled functions run, "i" is 3 everywhere. let creates a fresh binding for every iteration, so each callback closes over its own i.

We can see the same behaviour without timers:

const fns = []; for (var i = 0; i < 3; i++) { fns.push(() => console.log(i)); } fns.forEach(fn => fn()); // 3, 3, 3 const fns2 = []; for (let i = 0; i < 3; i++) { fns2.push(() => console.log(i)); } fns2.forEach(fn => fn()); // 0, 1, 2

Hoisting

Hoisting is the behavior where JavaScript sets up all declarations in a scope before running any code in that scope. It's often described as declarations being "moved to the top," but nothing actually moves. The engine runs in two passes: first it scans the scope and registers every declared name, then it executes the code line by line. Hoisting is the effect of that first pass.

  • var: hoisted and initialized to undefined
  • let and const: hoisted but uninitialized (temporal dead zone)
  • Function declarations: fully hoisted (you can call them before the line they're defined on)
  • Function expressions and arrow functions: follow the variable's rules
  • Classes: behave like let

What will be the output of this code:

console.log(typeof foo); // "function" var foo = 1; function foo() {} console.log(typeof foo); // "number"

If a function declaration and a var share a name, the function wins during hoisting, and a later var assignment overwrites it at runtime.

Variable scopes

  • Global scope: declared outside any function or block
  • Function scope: declared inside a function
  • Block scope: a block is anything inside {}. Works only for let and const.
  • Module scope: top-level variables in an ES module are private to that file unless exported

The scope chain

When you use a variable, JavaScript looks for it in the current scope first, then the one around it, then the next, all the way out to global. It searches outward, never inward. The chain is decided by where the code is written, not where it's called from. This is called lexical scope:

let a = "global"; function outer() { let b = "outer"; function inner() { let c = "inner"; console.log(a, b, c); // all visible: searches outward } inner(); console.log(c); // ReferenceError: can't look inward }

Shadowing

An inner variable with the same name as an outer one hides the outer one inside its scope. They are two independent variables; changing the inner one doesn't touch the outer one.

let name = "outer"; { let name = "inner"; console.log(name); // "inner" } console.log(name); // "outer"

Lexical Environment vs Execution Context vs Variable Environment

Lexical Environment: the data structure containing an Environment Record (local variables) and a reference to the outer/parent lexical environment. That outer reference is what forms the scope chain. Blocks, functions, catch clauses and modules each get one; it's where let, const and class live.

Variable Environment: a lexical environment at the function level used to store var declarations and function declarations (the ones with hoisting behavior).

Execution Context: the broader container (managed on the Call Stack) that holds the currently running code, its evaluation state, the this binding, and references to its Lexical and Variable Environments. A new one is created for the global code and for every function call.

Functions

First-class functions

In JS, functions are first-class citizens: they are values like any other.

const greet = function () { return "hi"; }; // assigned to a variable const list = [greet, () => "bye"]; // stored in an array const obj = { sayHi: greet }; // stored as a property [1, 2, 3].map(n => n * 2); // passed as an argument function makeAdder(a) { return b => a + b; // returned from a function } const add5 = makeAdder(5); // created at runtime add5(3); // 8

Because functions are values, you can pass and return them. Currying turns f(a, b) into f(a)(b). Partial application pre-fills some arguments. Composition chains functions (pipe(trim, lower, slugify)). These patterns give you reusable, testable logic, and they're the idea behind middleware, HOCs, and utilities like debounce and once.

Function declaration vs Function expression vs Arrow function

A function declaration is a statement that starts with the function keyword. A function expression creates a function as part of an expression, usually assigned to a variable.

// Declaration — fully hoisted function add(a, b) { return a + b; } // Expression — follows the variable's hoisting rules const add2 = function (a, b) { return a + b; }; // Arrow functions are always expressions const add3 = (a, b) => a + b;

Arrow functions are not just shorter syntax:

  • No own this. They take this from the surrounding scope, and call/apply/bind can't change it.
  • No own arguments (use rest params ...args).
  • Can't be used with new and have no prototype.

Rule of thumb: arrows for callbacks (they keep the outer this), regular functions or methods for object methods that need their own this.

IIFE

An IIFE (Immediately Invoked Function Expression) is a function that's defined and run in the same step:

(function () { console.log("runs immediately"); })();

Before modules and let, it was the way to create a private scope and avoid polluting globals. Today modules and blocks cover that, but you still see an async IIFE to use await where top-level await isn't available: (async () => { await init(); })();

Closure

A closure is a function bundled together with references to its surrounding state (the lexical environment). In other words, a function remembers the variables from where it was created, even after the outer function has returned. Closures are created every time a function is created.

function createCounter() { let count = 0; // private, only reachable through the closure return { inc: () => ++count, get: () => count, }; } const c = createCounter(); c.inc(); c.inc(); c.get(); // 2

this keyword

this belongs to the execution context, not the lexical environment. But the execution context uses the lexical environment to resolve variables. So:

  • Lexical Environment = scope + variables
  • Execution Context = runtime frame + this + pointers to LEs
  • this = a runtime binding stored inside the execution context

this tells the function who called it. It's not part of scope and not determined by where the function is written. It is determined only by how the function is called at runtime (arrow functions are the exception, see below).

  • Default binding (plain function call): when a function is called with no context object, this is undefined in strict mode, or the global object (window) in non-strict mode. Modules and classes are strict by default, so in modern code it's usually undefined.
  • Implicit binding (called as a method): with obj.method(), this is whichever object is immediately to the left of the dot at the call site, not necessarily where the function was originally defined.
  • Explicit binding (call, apply, bind): you manually force what this should be.
  • new binding (constructor calls): new creates a brand new empty object, sets this to it, links its prototype, and returns it automatically (unless the function explicitly returns another object).
  • Arrow functions: no own this; they use the this of the scope they were written in.

Precedence: new > explicit > implicit > default.

const user = { name: "Ana", hi() { return this.name; }, }; user.hi(); // "Ana" (implicit) const hi = user.hi; hi(); // TypeError in strict mode: this is undefined (binding lost) hi.call({ name: "Bo" }); // "Bo" (explicit) setTimeout(user.hi); // binding lost again — classic bug, fix with user.hi.bind(user)

call vs apply vs bind

All three set this explicitly.

  • fn.call(obj, a, b) invokes immediately, with arguments listed out.
  • fn.apply(obj, [a, b]) invokes immediately, with arguments as an array (mostly replaced by spread).
  • fn.bind(obj, a) returns a new function with this (and optionally leading args) permanently fixed, which also makes it useful for partial application.

A bound function's this can't be overridden by a later call or bind, though new can override it. Common uses are method borrowing (Array.prototype.slice.call(arguments)) and fixing this in callbacks.

Debounce vs Throttle

Both limit how often a function runs. A classic "write it from scratch" question.

  • Debounce: wait until the calls stop for ms, then run once. Search input, autosave, resize end.
  • Throttle: run at most once every ms, no matter how many calls. Scroll, mousemove, drag.
function debounce(fn, ms) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), ms); }; } function throttle(fn, ms) { let last = 0; return function (...args) { const now = Date.now(); if (now - last >= ms) { last = now; fn.apply(this, args); } }; }

Objects & OOP

Prototypes, prototype chain and prototypal inheritance

In JavaScript, every object has a hidden link to another object called its prototype. When you access a property that the object doesn't have, JavaScript follows that link and looks on the prototype, then the prototype's prototype, and so on until it finds the property or reaches null. This sequence of links is the prototype chain, and sharing behavior through it is prototypal inheritance.

How to create an object from another object?

const animal = { eats: true, describe() { return `I eat: ${this.eats}`; } }; const rabbit = Object.create(animal); // rabbit's prototype is animal rabbit.describe(); // "I eat: true" — found on animal via the chain

Before ES6:

function Person(name) { this.name = name; // own property per instance } Person.prototype.greet = function () { return `Hi, I'm ${this.name}`; // shared by all instances }; const ana = new Person("Ana"); ana.greet(); // "Hi, I'm Ana" Object.getPrototypeOf(ana) === Person.prototype; // true

After ES6:

class Animal { constructor(name) { this.name = name; } speak() { return `${this.name} makes a sound`; } } class Dog extends Animal { speak() { return `${super.speak()}, specifically a woof`; } } const d = new Dog("Rex"); d.speak(); // "Rex makes a sound, specifically a woof" // Under the hood: Object.getPrototypeOf(d) === Dog.prototype; // true Object.getPrototypeOf(Dog.prototype) === Animal.prototype; // true

__proto__ exists on (effectively) every object and points to that object's actual prototype, the next link in its chain. It's a getter/setter inherited from Object.prototype, and it's the legacy way of doing what Object.getPrototypeOf(obj) and Object.setPrototypeOf(obj, p) do today.

prototype is a regular property that exists only on functions that can be used as constructors (normal functions and classes, not arrow functions or methods). It is not the function's own prototype. It's the object that will be assigned as the __proto__ of instances created with new.

function Person(name) { this.name = name; } const ana = new Person("Ana"); ana.__proto__ === Person.prototype; // true ← the key link Person.__proto__ === Function.prototype; // true (Person is itself a function object) Person.prototype.__proto__ === Object.prototype; // true ana.prototype; // undefined (ana isn't a function) Person.prototype.constructor === Person; // true (back-reference)

Classes

class is syntax over prototypes, but a few details matter:

  • Private fields (#x) are truly private, enforced by the engine, not by convention. #x in obj is a brand check.
  • Class fields (count = 0, or handle = () => {}) are created per instance as own properties. Methods live once on the prototype. An arrow-function field fixes this but costs one function per instance.
  • static members and static {} blocks belong to the class itself.
  • super calls the parent. In a derived constructor you must call super() before touching this.
  • new.target tells you whether the function was called with new, or which class was actually instantiated (useful for abstract classes).
class Counter { #n = 0; static create() { return new Counter(); } get value() { return this.#n; } inc() { this.#n++; } }

Composition vs Inheritance

  • Inheritance models "is-a": Dog extends Animal. Simple for shallow, stable hierarchies, but deep trees get brittle. A change in the base class ripples into every child (fragile base class), and you inherit everything even if you need one method ("you wanted a banana but got the gorilla holding the banana").
  • Composition models "has-a" / "can-do": build objects from small, independent pieces and combine only what you need.
const canFly = (state) => ({ fly: () => `${state.name} flies` }); const canSwim = (state) => ({ swim: () => `${state.name} swims` }); function createDuck(name) { const state = { name }; return { ...canFly(state), ...canSwim(state) }; } createDuck("Donald").swim(); // "Donald swims"

"Favor composition over inheritance" is the default answer. React is built on it: components compose via children and props, and logic is shared through custom hooks, not class hierarchies. Inheritance is still fine for small, stable cases, like custom Error classes.

Proxy & Reflect

A Proxy wraps an object and intercepts operations (get, set, has, deleteProperty...) through traps. Reflect mirrors those operations as functions so you can forward them to the original behavior correctly.

const user = new Proxy({ age: 30 }, { set(target, key, value, receiver) { if (key === "age" && typeof value !== "number") { throw new TypeError("age must be a number"); } return Reflect.set(target, key, value, receiver); // do the default thing }, }); user.age = 31; // ok user.age = "old"; // TypeError

Why Reflect instead of target[key] = value: it returns true/false like the trap expects, and passing receiver keeps this correct for getters/setters and inherited properties.

Real uses: reactive systems (Vue 3's reactivity, MobX), validation, Immer's "mutate a draft, get an immutable result", logging/mocking. The cost is some performance overhead and behavior that is harder to debug, so use it deliberately.

Modules

ES Modules vs CommonJS

  • ESM (import/export) is static, so bundlers can tree-shake it. It has live bindings (imports reflect later changes in the exporter), supports top-level await and dynamic import() (code splitting), is loaded asynchronously, and is strict by default.
  • CommonJS (require/module.exports) is dynamic and synchronous, so it can't be reliably tree-shaken. When you destructure from require, you get the values as they were at that moment, not live bindings.

ESM is the standard for browsers and modern Node. CommonJS mostly lives in older Node code and packages.

Asynchronous JavaScript

Synchronous vs Asynchronous

Synchronous code runs top-to-bottom and blocks the thread until each line finishes. Asynchronous code starts work now and handles the result later, without blocking.

The JS language itself is single-threaded and synchronous. Async behavior comes from the environment (browser or Node): timers, network and I/O run outside the JS thread, and their callbacks are queued and executed later by the event loop. So a long synchronous task blocks everything, including clicks and rendering.

Event Loop & Task Queues

The event loop's job is to check the call stack continuously. When the call stack is empty, it takes the next task from a queue and pushes it onto the stack.

There are two kinds of queues:

  1. Macrotask (task) queue: setTimeout, setInterval, I/O, user events (click, input), MessageChannel. Node also has setImmediate.
  2. Microtask queue: promise callbacks (then, catch, finally), code after await, queueMicrotask(), MutationObserver.

One loop iteration:

  1. Run one macrotask (the initial script is the first one).
  2. Run all microtasks, including ones added while draining.
  3. The browser may render (requestAnimationFrame callbacks → style → layout → paint).
  4. Repeat.

Microtasks always beat macrotasks. That's also a trap: a microtask that keeps scheduling microtasks starves the loop and freezes the page, because rendering never gets a turn.

What's the output?

console.log(1); setTimeout(() => console.log(2)); Promise.resolve().then(() => console.log(3)); queueMicrotask(() => console.log(4)); console.log(5); // 1, 5, 3, 4, 2
async function f() { console.log("a"); await null; // everything below becomes a microtask console.log("b"); } console.log("start"); f(); console.log("end"); // start, a, end, b

The body of an async function runs synchronously until the first await.

Promises and async/await

A promise is a state machine (pending → fulfilled/rejected) that settles once. then returns a new promise, which is what makes chaining work. async/await is syntax sugar over promises: an async function always returns a promise, and await pauses that function (not the thread) until the promise settles.

// promise chain fetchUser(id) .then(user => fetchPosts(user.id)) .then(posts => render(posts)) .catch(handleError) .finally(hideSpinner); // same thing with async/await async function load(id) { try { const user = await fetchUser(id); const posts = await fetchPosts(user.id); render(posts); } catch (err) { handleError(err); } finally { hideSpinner(); } }

Combinators:

  • Promise.all: waits for all, fails fast on the first rejection.
  • Promise.allSettled: waits for all, never rejects; gives you { status, value | reason } for each.
  • Promise.race: first to settle (fulfilled or rejected) wins.
  • Promise.any: first fulfilled wins (rejects with AggregateError if all fail).

Common mistakes:

  • Sequential awaits in a loop when the work is independent and could run in parallel.
  • array.forEach(async ...) doesn't wait for anything. Use for...of (sequential) or Promise.all(array.map(...)) (parallel).
  • Forgetting try/catch or .catch(), which leads to unhandled rejections.
  • Forgetting return inside .then, which breaks the chain.

Parallel, Sequence, and Race

// Sequence: one after another. Use when each step depends on the previous one. const results = []; for (const id of ids) { results.push(await fetchUser(id)); // total time = sum of all requests } // Parallel: start all at once, wait for all. const users = await Promise.all(ids.map(fetchUser)); // total time = slowest request // Race: e.g. a timeout const withTimeout = (promise, ms) => Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), ms)), ]);

Senior details:

  • Firing 1000 requests with Promise.all can overload the server or hit browser connection limits. Use limited concurrency (process in batches, or a pool such as p-limit).
  • race doesn't cancel the loser; it keeps running. To actually cancel a fetch, use AbortController (fetch(url, { signal }), or AbortSignal.timeout(ms)).

Concurrency vs Parallelism & Threads

  • Concurrency: dealing with many tasks at once by switching between them. One cook preparing three dishes, moving between them while each one simmers.
  • Parallelism: doing many tasks at literally the same time. Three cooks, three dishes.
  • Thread: an independent line of execution with its own call stack. Threads in the same process share memory.

JavaScript on the main thread is concurrent but not parallel: one call stack, and the event loop interleaves tasks. The browser itself is multi-threaded (network, parsing, compositor, raster threads), and Node runs fs/crypto/DNS work on a libuv thread pool. That's why waiting for I/O doesn't block JS.

Single-threaded vs Multi-threaded

Single-threaded (JS main thread)Multi-threaded
Shared stateNo data races, no locks neededNeeds locks/atomics, risk of race conditions and deadlocks
SimplicityEasy to reason aboutHarder to write and debug
CPU-heavy workBlocks everything, the UI freezesCan use multiple cores

To get real parallelism in JS:

  • Web Workers (browser) / worker_threads (Node): separate threads with their own event loop and no DOM access. They talk via postMessage, which copies data (structured clone), or transfers ownership of an ArrayBuffer for zero-copy.
  • SharedArrayBuffer + Atomics give truly shared memory (the page has to be cross-origin isolated).

If the main thread is busy for more than 50ms, that's a long task and it hurts INP. Fixes: move work to a worker, split it into chunks and yield between them (await scheduler.yield() or setTimeout), or do less work.

Engine & Memory

How the JS Engine Works

  1. Parse the source into an AST
  2. Compile it to bytecode
  3. Run it in an interpreter
  4. Hot code is recompiled by a JIT compiler into optimized machine code. (The optimizer makes assumptions, like "this object always has the same shape".)
  5. When an assumption breaks, the code is deoptimized back to slower bytecode.

Tokens (lexical analysis): the lexer reads characters and groups them into meaningful chunks. It has no idea what they mean together. The acorn library can be used to see the tokenization output for code. For const total = 2 + 3 * 4;:

Keyword const Identifier total Punctuator = Numeric 2 Punctuator + Numeric 3 Punctuator * Numeric 4 Punctuator ;

AST (syntax analysis): the parser takes those tokens and applies the grammar rules. Here is the same code as a tree, trimmed of position info. Notice how 3 * 4 sits deeper than +; operator precedence is encoded in the tree's shape:

{ "type": "Program", "body": [{ "type": "VariableDeclaration", "kind": "const", "declarations": [{ "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "total" }, "init": { "type": "BinaryExpression", "operator": "+", "left": { "type": "Literal", "value": 2 }, "right": { "type": "BinaryExpression", "operator": "*", "left": { "type": "Literal", "value": 3 }, "right": { "type": "Literal", "value": 4 } } } }] }] }

Ignition: V8's interpreter. It takes the AST, produces compact bytecode, and runs it right away, without waiting for machine-code compilation. That's why JS starts fast. Bytecode is much smaller than machine code and cheap to generate, which is the trade-off: fast startup, slower steady-state speed.

TurboFan: V8's optimizing compiler. It turns hot functions into fast machine code using the type feedback Ignition collected. (Modern V8 also has middle tiers, Sparkplug and Maglev, between the two.)

Ignition runs bytecode ──► records types in feedback slots ▲ │ │ ▼ deopt (guard fails) ◄── TurboFan compiles machine code using those types

Optimization techniques

Hidden Classes (called "Maps" or "shapes" inside V8): when you create an object, V8 assigns it a hidden class, an internal description of its structure.

const user = { name: "Tural", age: 30 }; // HiddenClass: // name → offset 0 // age → offset 1
  • V8 can access properties by fixed offsets instead of slow dictionary lookups.
  • Objects that get the same properties in the same order share a hidden class. Adding properties in a different order, adding them later, or using delete creates new hidden classes and makes code slower.
  • In practice: initialize all properties in the constructor, always in the same order.

Inline Caches: when a property access runs repeatedly, V8 remembers where that property lives for the shapes it has seen.

function greet(u) { return u.name; }
  1. The first call looks up name on the object → slow.
  2. V8 caches "for this hidden class, name is at offset 0". The next calls with the same shape skip the lookup.

Monomorphic = same object shape every time → fast path.

Polymorphic = a few shapes → slower.

Megamorphic = too many shapes → the cache gives up and falls back to slow generic lookups.

Memory: Stack, Heap and Garbage Collection

  • The stack holds call frames: local variables, primitives, and references (pointers). It's fast, and frames are freed automatically when a function returns.
  • The heap holds objects, arrays, closures, and strings. Their lifetime isn't tied to a function call, so something has to figure out when they're dead. That something is the garbage collector.

Common memory leaks (a memory leak = memory that is still reachable but no longer needed):

  • Event listeners that are never removed (especially on window/document)
  • setInterval that is never cleared
  • Closures holding big objects longer than needed
  • Detached DOM nodes still referenced from JS
  • Unbounded caches/Maps and accidental globals