JavaScript for Backend Developers — Part 4: Modern Syntax, ES Modules & WeakMap Mastery | FreeLearning365

JavaScript for Backend Developers — Part 4: Modern Syntax, ES Modules & WeakMap Mastery | FreeLearning365

🚀 JavaScript for Backend Developers
🗺️ The Complete 7-Part Journey
Navigate anywhere in the series
Learning Path Part 1 of 7
🏆 Part 7 → AI + Observability + Production + 3 Capstone Projects

JavaScript for Backend Developers — Part 4: Modern Syntax, ES Modules & WeakMap Mastery | FreeLearning365
Part 4 of 7 · JavaScript for Backend Developers

Modern Syntax, ES Modules &
The WeakMap Superpower.

You survived prototypes and this. Now let's make your day-to-day JavaScript delightful. Optional chaining, nullish coalescing, ES Modules, generators, WeakMap, BigInt, and every modern feature that turns you from "struggling with JS" into "fluent in JS".

📖 ~70 min deep read 🧪 8 interactive demos ⚡ 75+ code examples 🤖 CJS→ESM migration AI

01 · The 2015–Present JavaScript Revolution

In Parts 1–3, you learned how JavaScript works: the event loop, async patterns, the object model, and this. In Part 4, you learn how JavaScript feels in 2026 — the features that turned it from a language you endure into one you enjoy.

Between 2015 (ES6) and today, JavaScript gained roughly one major release per year. Each release added small quality-of-life features that, cumulatively, transformed the language. Most importantly for you as a backend developer, these features also reached Node.js — and Node 22+ now runs essentially the entire modern specification.

🎯

Fewer null checks

Optional chaining and nullish coalescing eliminate 90% of defensive code.

📦

Real modules

ES Modules are now the standard. CommonJS is legacy. Migration is easier than you think.

🪄

Lazy everything

Dynamic imports, generators, top-level await — load only what you need, when you need it.

🧠

Memory that behaves

WeakMap and FinalizationRegistry give you garbage collection control that Java has had for years.

🔢

Numbers without lies

BigInt. Numeric separators. Real big-number arithmetic that doesn't drift.

🛠️

Arrays that understand intent

.at(), .findLast(), Object.groupBy() — no more hand-rolled helpers.

🚀

The mindset shift: If you learned JavaScript before 2018 and haven't revisited it since, you're writing a different language than what modern teams ship. This article is your catch-up — every feature you need to know to read any codebase from the last five years.

02 · Optional Chaining ?.

Optional chaining is the single most impactful feature for day-to-day backend code. It replaces the classic && guards that clutter every API response handler.

Before and After — The Common Case

Before const city = user && user.address && user.address.city;
After const city = user?.address?.city;

Same result: if user or user.address is null or undefined, the whole expression short-circuits to undefined. No TypeError. But the modern form is dramatically cleaner, and — crucially — it doesn't swallow falsy values like 0 or '' the way && does.

⚠️

The && trap: user && user.address short-circuits on ANY falsy value — including 0, '', false, NaN. user?.address only short-circuits on null or undefined. That's almost always what you actually want.

Three Forms You Need to Know

optional-forms.js JavaScript
const response = {
  status: 200,
  data: {
    user: { profile: { name: 'Alice' } },
    fetchMeta: function () { return 'cached'; }
  }
};

// 1. Property access — ?.  →  undefined if nullish
const name = response?.data?.user?.profile?.name;
// 'Alice' — walks the chain safely

const missing = response?.data?.missing?.deeply?.nested;
// undefined — short-circuits at 'missing'

// 2. Method call — ?.()  →  undefined if method doesn't exist
const meta = response?.data?.fetchMeta?.();
// 'cached'

const noMethod = response?.data?.nonExistent?.();
// undefined — does NOT throw TypeError

// 3. Bracket access — ?.[]  →  for dynamic keys
const key = 'status';
const value = response?.[key];
// 200

Interactive: Optional Chaining Explorer

Interactive — Try Different Access Chains

Real Backend Pattern — Normalizing External API Responses

When consuming third-party APIs, the shape of the response is not under your control. Optional chaining lets you extract fields safely without defensive cruft:

normalize.js JavaScript
async function fetchUserProfile(userId) {
  const res = await fetch(`/api/users/${userId}`);
  const body = await res.json();

  // Extract deeply nested fields safely
  return {
    id: body?.data?.user?.id ?? null,
    email: body?.data?.user?.email?.toLowerCase() ?? null,
    name: body?.data?.user?.profile?.displayName ?? body?.data?.user?.email ?? 'Anonymous',
    avatarUrl: body?.data?.user?.profile?.avatar?.url ?? '/default-avatar.png',
    role: body?.data?.user?.roles?.[0] ?? 'user',
    lastLoginAt: body?.data?.user?.activity?.lastLoginAt ?? null
  };
}

Notice how each field has a fallback via ?? (nullish coalescing — next section). Combined, these two operators are the workhorses of modern API integration.

🎯

The pattern that pays off: define a safeGet wrapper once and reuse it everywhere:

const safe = (fn, fallback = null) => { try { return fn() ?? fallback; } catch { return fallback; } };

Then safe(() => res.data.user.profile.name, 'Anonymous') handles every edge case in one line.

03 · Nullish Coalescing ??

?? returns the right-hand side only if the left is null or undefined. That's it. It's the safe replacement for || in almost every backend scenario.

Why || Is Often Wrong

Bug const port = env.PORT || 3000; // 0 → 3000 ❌
Fix const port = env.PORT ?? 3000; // 0 → 0 ✅

The || operator treats all falsy values the same: 0, '', false, NaN, null, undefined. But for config and user data, you often want to allow 0 and '' as valid values.

When Each Operator Is Right

Scenario Use || Use ??
Default value when user provides nothing
Allow 0, '', false as valid inputs
Boolean coercion with fallback for any falsy
Config with numeric defaults (0 valid)
String defaults (empty string valid)
Feature flags with explicit false
"Show placeholder if not set" (any falsy OK)
💡

Rule of thumb: default to ??. Use || only when you genuinely want to reject every falsy value — and if you do, comment why.

Real Backend Code — Config Loading

config.js JavaScript
function loadConfig(env = process.env) {
  return {
    // Ports: 0 means "any available port" — must be preserved!
    port: Number(env.PORT ?? 3000),

    // String defaults — empty string is a valid host meaning "any"
    host: env.HOST ?? '0.0.0.0',

    // Log level: empty string or "silent" both suppress logs
    logLevel: env.LOG_LEVEL ?? 'info',

    // Booleans: strings from env — explicit parsing
    debugMode: env.DEBUG === 'true',
    trustProxy: env.TRUST_PROXY === 'true',

    // Timeouts: 0 means "no timeout" in many systems
    requestTimeoutMs: Number(env.REQUEST_TIMEOUT_MS ?? 30_000),

    // DB pool: 0 is a valid but unusual setting; allow it
    dbPoolSize: Number(env.DB_POOL_SIZE ?? 10)
  };
}

Combining ?. and ?? — The Ultimate Pattern

combo.js JavaScript
// Extract any deeply nested field with a fallback, in one line.
function extract(obj, path, fallback = null) {
  const result = path.split('.').reduce((acc, key) => acc?.[key], obj);
  return result ?? fallback;
}

const apiResponse = {
  data: {
    user: { name: 'Alice', prefs: { theme: '', language: null } }
  }
};

extract(apiResponse, 'data.user.name');                    // 'Alice'
extract(apiResponse, 'data.user.prefs.theme');             // '' (empty string preserved)
extract(apiResponse, 'data.user.prefs.language', 'en');   // 'en' (null → fallback)
extract(apiResponse, 'data.missing.deep.path', 'default'); // 'default'

04 · Logical Assignment ||=, &&=, ??=

Three operators that combine assignment with logical fallback. They're newer (ES2021) and dramatically reduce the "check-then-assign" boilerplate that littered older code.

Before if (!cache[key]) cache[key] = compute();
After cache[key] ??= compute();

The Three Operators — Each With a Purpose

logical-assign.js JavaScript
// ??=  — assign only if currently null or undefined
let config = null;
config ??= { debug: false };
console.log(config);  // { debug: false }

config ??= { debug: true };   // no change — config is not nullish
console.log(config);  // { debug: false }

// ||=  — assign only if currently falsy (any falsy value)
let timeout = 0;
timeout ||= 5000;
console.log(timeout);  // 5000 — 0 was falsy

let name = 'Alice';
name ||= 'Anonymous';
console.log(name);  // 'Alice' — truthy, no change

// &&=  — assign only if currently truthy
let user = { name: 'Bob', verified: true };
user.verified &&= user.name === 'Bob';
console.log(user.verified);  // true (both truthy)

let empty = '';
empty &&= 'fallback';
console.log(empty);  // '' (empty string is falsy → skip)

Real Backend Use — Smart Cache & Config Merging

smart-cache.js JavaScript
// Lazy cache initialization — cleaner than "if (!cache[key])"
const cache = new Map();

function getCached(key, factory) {
  if (!cache.has(key)) {
    cache.set(key, factory());
  }
  return cache.get(key);
}

// Or, using nullish logical assignment for object-based cache:
const objCache = Object.create(null);

function getCachedObj(key, factory) {
  objCache[key] ??= factory();
  return objCache[key];
}

// Idempotent config initialization — perfect for env fallbacks
const config = {};
config.dbUrl ??= process.env.DB_URL;
config.dbUrl ??= 'postgres://localhost/dev';
// dbUrl is set to the first non-nullish value

// Middleware flags — only set if truthy
let options = { cors: true, rateLimit: 100 };
options.rateLimit &&= options.rateLimit > 0;
// If rateLimit is truthy AND > 0, keep it; otherwise set to false

Backend win: options.retries ??= 3 is exactly the intent behind every "use default if not set" pattern. It replaces 3 lines of defensive code with 1, and it doesn't accidentally treat 0 as "missing".

05 · ES Modules — The Real Module System

For the first 20 years of its life, JavaScript had no module system. Everything was global. Then came CommonJS (require / module.exports), then AMD, then UMD. Finally, in ES6, the language got native modules: import and export.

For backend developers, this matters because Node.js now supports ESM natively (since Node 14) and the ecosystem is migrating rapidly. If you're still writing require(), you're using a legacy system that has real limitations.

📦

CommonJS vs ESM: CommonJS is like a mail-room — you request packages on demand, and they're delivered when you ask. ESM is like a pre-flight checklist — the engine parses all imports before executing anything. This gives ESM faster startup, better tree shaking, and true static analysis.

The Six Import/Export Forms

user-service.js — the module JavaScript
// 1. Named export — the most common form
export function findById(id) { /* ... */ }
export function create(data) { /* ... */ }
export const MAX_PAGE_SIZE = 100;

// 2. Export at the bottom — common when refactoring
function update(id, data) { /* ... */ }
function deleteById(id) { /* ... */ }
export { update, deleteById };

// 3. Rename on export — public API differs from internal name
function internalValidate() { /* ... */ }
export { internalValidate as validate };

// 4. Default export — one main thing per module
export default class UserService { /* ... */ }

// 5. Re-export from another module (barrel pattern)
export { findById } from './queries.js';
export * from './validators.js';

// 6. Namespace export
export * as userApi from './api.js';
app.js — consuming the module JavaScript
// Named imports
import { findById, create, MAX_PAGE_SIZE } from './user-service.js';

// Rename on import
import { internalValidate as validate } from './user-service.js';

// Default import (name can be anything)
import UserService from './user-service.js';

// Namespace import — everything grouped under one name
import * as userService from './user-service.js';
userService.findById(1);

// Combined default + named
import UserService, { findById, create } from './user-service.js';

// Node.js built-ins use node: prefix (recommended)
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';

CommonJS → ESM — The Full Cheat Sheet

CommonJS ESM
const x = require('mod')import x from 'mod'
const { a, b } = require('mod')import { a, b } from 'mod'
module.exports = valueexport default value
module.exports = { a, b }export { a, b }
exports.a = aexport { a } or export const a = …
require('./x')import './x.js' (extension required!)
__dirnameimport.meta.dirname (Node 20.11+) or import.meta.url
__filenameimport.meta.filename (Node 20.11+)
require.resolve()import.meta.resolve()
require.main === moduleimport.meta.main (Node 24+)
📁

Enabling ESM in Node.js — three options:

1. Set "type": "module" in package.json — every .js file is now ESM.
2. Use .mjs extension for ESM files alongside .cjs for CJS.
3. Use .js files with explicit .mjs/.cjs for edge cases.
Option 1 is the modern standard for new projects.

Interactive: Module Graph Visualizer

Interactive — Import Resolution

06 · Dynamic Import & Lazy Loading

Static import statements are evaluated before your code runs. That's great for predictable dependencies but terrible for optional features, plugins, or heavy modules that only some requests need.

Dynamic importimport('./module.js') — returns a promise that resolves to the module. You can call it anywhere: inside functions, inside conditionals, inside loops, based on runtime data.

dynamic-import.js JavaScript
// Static import — always loaded, always parsed
import { heavyCompression } from './compression.js';

// Dynamic import — loaded only when actually needed
async function processUpload(file, options) {
  if (options.compress) {
    // Only loads the compression module when compress is true
    const { heavyCompression } = await import('./compression.js');
    file = heavyCompression(file);
  }
  return upload(file);
}

// Plugin loading based on config
async function loadPlugins(pluginNames) {
  const plugins = await Promise.all(
    pluginNames.map((name) => import(`./plugins/${name}.js`))
  );
  return plugins.map((m) => m.default);
}

// Conditional polyfill loading
async function ensureCryptoSupport() {
  if (!globalThis.crypto) {
    await import('./polyfills/crypto.js');
  }
}

The Startup Performance Win

In a large Node.js app, dynamic imports can cut cold-start time dramatically:

lazy-routes.js JavaScript
// ❌ Static imports — every route's deps load at startup
import adminController from './controllers/admin.js';
import reportController from './controllers/reports.js';
import analyticsController from './controllers/analytics.js';
import csvExporter from './exporters/csv.js';
// ... 40 more — total startup overhead: 1.2 seconds

// ✅ Dynamic imports — only loaded when a request actually hits them
app.get('/admin/users', async (req, res) => {
  const adminController = (await import('./controllers/admin.js')).default;
  await adminController.listUsers(req, res);
});

app.'/reports/monthly', async (req, res) => {
  const reportController = (await import('./controllers/reports.js')).default;
  await reportController.generate(req, res);
});

// Startup time drops by 80%+. Cold starts in serverless become fast.
// Modules are cached after first load, so subsequent calls are instant.
⚠️

Don't overuse dynamic imports. They make code harder to trace, break static analysis tools, and prevent bundlers from tree-shaking. Use them for:

• Optional features (plugins, formats, export types).
• Heavy dependencies loaded conditionally.
• Serverless/lambda cold-start optimization.
Not for every import "just in case".

07 · Top-Level Await

For most of JavaScript's history, await only worked inside async functions. You needed to wrap your top-level code in an IIFE or .then() chain. In ES modules, you can await at the top level directly.

top-level-await.mjs JavaScript
// ❌ The old way — wrap everything in an async IIFE
(async () => {
  const config = await loadConfig();
  const server = await createServer(config);
  server.listen(config.port);
})();

// ✅ Modern way — top-level await directly in ESM
import { loadConfig } from './config.js';
import { createServer } from './server.js';

const config = await loadConfig();
const server = await createServer(config);

server.listen(config.port);
console.log(`Listening on ${config.port}`);

Real Backend Pattern — Async Module Initialization

db.js — async DB pool initialization JavaScript
import pg from 'pg';
import { readFile } from 'node:fs/promises';

// Top-level await — pool is ready before anyone imports this module
const secrets = JSON.parse(await readFile('/run/secrets/db.json', 'utf8'));

export const pool = new pg.Pool({
  host: secrets.host,
  port: secrets.port,
  user: secrets.user,
  password: secrets.password,
  database: secrets.database,
  max: 20
});

// Warm up the pool — first request won't pay the connection cost
await pool.query('SELECT 1');

console.log('[db] pool ready');

Now any module that imports pool is guaranteed it's connected and warm. No more "database not initialized" race conditions in startup.

🚫

Top-level await is not free. It blocks the module graph. If module A uses top-level await, every module that imports A must wait for A. This can hurt cold-start time in serverless. Use it for essential initialization (DB pools, config loading), not for optional data fetching.

08 · Generators in Production

Generators are functions that can pause and resume. They're the mechanism behind async/await, and they're incredibly powerful for streaming, lazy evaluation, and state machines.

generator-basics.js JavaScript
function* numberStream() {
  console.log('starting');
  yield 1;
  console.log('resumed');
  yield 2;
  console.log('resumed again');
  yield 3;
  return 'done';
}

const gen = numberStream();

gen.next();  // logs 'starting' → { value: 1, done: false }
gen.next();  // logs 'resumed'  → { value: 2, done: false }
gen.next();  // logs 'resumed again' → { value: 3, done: false }
gen.next();  // { value: 'done', done: true }
gen.next();  // { value: undefined, done: true }

// Iteration is automatic with for...of
for (const n of numberStream()) {
  console.log(`got ${n}`);
  // Logs: starting, got 1, resumed, got 2, resumed again, got 3
}

Interactive: Infinite Generator Demo

Interactive — Generators Are Lazy

The Killer Feature — Lazy Pipelines

Generators are lazy: nothing is computed until you ask for it. Combined with generators, you can process infinite streams or enormous datasets with O(1) memory.

lazy-pipeline.js JavaScript
// Composable generator utilities — the "lazy" versions of array methods
function* range(start, end, step = 1) {
  for (let i = start; i < end; i += step) yield i;
}

function* lazyMap(iter, fn) {
  for (const item of iter) yield fn(item);
}

function* lazyFilter(iter, pred) {
  for (const item of iter) {
    if (pred(item)) yield item;
  }
}

function* take(iter, n) {
  let count = 0;
  for (const item of iter) {
    if (count++ >= n) return;
    yield item;
  }
}

// Compose: first 5 even squares of 1..∞
const pipeline = take(
  lazyFilter(
    lazyMap(range(1, 1_000_000_000), (n) => n * n),
    (n) => n % 2 === 0
  ),
  5
);

console.log([...pipeline]);  // [4, 16, 36, 64, 100]

// Notice: we iterated range 1..1_000_000_000 in our heads,
// but the generator only computed values up to where 'take' stopped.
// Memory: constant. Time: proportional to what was consumed.
💡

Why this is a superpower: The array version range(1, 1e9).map(...).filter(...).slice(0,5) would allocate a billion-element array, blow up memory, and crash the process. The generator version uses constant memory and finishes in microseconds. This is how you process log streams, database cursors, and CSV files.

Real Backend Pattern — Async Pagination Stream

cursor-pagination.js JavaScript
// Async generator — combines the event loop, promises, and generators
async function* streamUsers(db, { pageSize = 100 } = {}) {
  let cursor = null;
  while (true) {
    const { rows, nextCursor } = await db.queryUsers({ cursor, limit: pageSize });
    for (const user of rows) yield user;
    if (!nextCursor) break;
    cursor = nextCursor;
  }
}

// Consume — constant memory regardless of total users
async function sendWelcomeEmails(db, mailer) {
  let sent = 0;
  for await (const user of streamUsers(db, { pageSize: 50 })) {
    if (user.welcomeSent) continue;
    await mailer.send(user.email, 'welcome');
    sent++;
    if (sent % 100 === 0) console.log(`Sent ${sent}`);
  }
  return sent;
}

Bonus — State Machines with Generators

Generators are also ideal for step-by-step state machines — a pattern that appears in protocol implementations, retry workflows, and long-running processes:

state-machine.js JavaScript
function* deployWorkflow() {
  const build = yield { step: 'build', description: 'Compiling source' };
  if (build.failed) {
    yield { step: 'notify-failure', description: 'Alerting team' };
    return;
  }

  const test = yield { step: 'test', description: 'Running test suite' };
  if (test.coverage < 80) {
    yield { step: 'warn-coverage', description: 'Low coverage' };
  }

  yield { step: 'deploy', description: 'Pushing to production' };
  yield { step: 'verify', description: 'Health checking' };
  return { ok: true };
}

// Driver — the caller decides what to feed back in
const gen = deployWorkflow();
let result = gen.next();

while (!result.done) {
  console.log(`Step: ${result.value.step} — ${result.value.description}`);
  // Execute the step, then feed result back in
  const stepResult = await executeStep(result.value);
  result = gen.next(stepResult);
}

console.log(`Deployment done: ${JSON.stringify(result.value)}`);

09 · WeakMap, WeakSet, WeakRef — Memory That Behaves

In Part 3, you learned that objects are compared by reference and that this creates subtle bugs. Now we take the next step: WeakMap and WeakSet let you attach data to objects without preventing them from being garbage-collected.

🪶

The sticky-note metaphor: A regular Map is like gluing a note to an object — the glue holds the object in place forever (memory leak). A WeakMap is like a magic sticky note that disappears when the object goes away. The object's lifespan is unaffected by the note.

The Three Weak Collections

Collection Keys/Values Use Case
WeakMap Object keys → any value Attach metadata to objects without preventing GC
WeakSet Objects only Track "seen" objects, mark objects as processed
WeakRef Holds a weak reference to one object Advanced caching, finalization hooks

The Classic Use Case — Private Data via WeakMap

weakmap-privacy.js JavaScript
// ES5-era pattern for private state — still used in old codebases
const privateData = new WeakMap();

class Counter {
  constructor() {
    privateData.set(this, { count: 0 });
  }

  increment() {
    const state = privateData.get(this);
    state.count++;
    return state.count;
  }

  getCount() {
    return privateData.get(this).count;
  }
}

// Private data is unreachable from outside
const c = new Counter();
c.increment();
console.log(c.getCount());      // 1
console.log(privateData.get(c));  // only accessible inside the module

// Modern equivalent — #private fields (from Part 3)
class CounterModern {
  #count = 0;
  increment() { return ++this.#count; }
  getCount() { return this.#count; }
}
// Same privacy, cleaner syntax, but WeakMap still useful for:
// - Attaching data to objects you don't control (e.g., DOM nodes, lib objects)
// - Cross-module private state without subclassing

Real Backend Use Case — Request Metadata

A classic pattern: attach request-scoped data to the request object without modifying it. WeakMap ensures the metadata is garbage-collected when the request ends.

request-context.js JavaScript
// Attach request-scoped metadata without polluting the request object
const requestMeta = new WeakMap();

function withContext(middleware) {
  return function (req, res, next) {
    const context = {
      requestId: crypto.randomUUID(),
      startTime: Date.now(),
      userId: null,
      tags: {}
    };
    requestMeta.set(req, context);
    return middleware(req, res, next);
  };
}

// Anywhere in your app — retrieve context by reference
function getContext(req) {
  return requestMeta.get(req);
}

function logger(req) {
  const ctx = getContext(req);
  return {
    info(msg, meta) { console.log(`[${ctx.requestId}]`, msg, meta); },
    error(msg, meta) { console.error(`[${ctx.requestId}]`, msg, meta); }
  };
}

// When the response is sent and the request object is released,
// the metadata is garbage-collected automatically.
// No memory leak from long-lived processes handling millions of requests.

The Massive Win — No Memory Leak

Leak const meta = new Map(); meta.set(req, { ... }); // req leaks forever
Safe const meta = new WeakMap(); meta.set(req, { ... }); // req collected after response

In a server processing millions of requests, this distinction is the difference between a stable service and one that dies with "JavaScript heap out of memory" every few days.

WeakSet — Object Membership Tracking

weakset.js JavaScript
// Track which objects have been processed — no memory leak
const processed = new WeakSet();

function processOnce(obj) {
  if (processed.has(obj)) return;
  processed.add(obj);

  // Do the expensive work
  console.log('Processing:', obj.id);
}

// Use case: avoiding cycles in graph traversal
function walkGraph(node, seen = new WeakSet()) {
  if (seen.has(node)) return;
  seen.add(node);

  for (const child of node.children ?? []) {
    walkGraph(child, seen);
  }

  visit(node);
}

WeakRef & FinalizationRegistry — Advanced Territory

These are advanced tools you should rarely need. But knowing they exist helps you understand the boundaries of JavaScript's memory model.

weakref.js JavaScript
// WeakRef — hold a reference that doesn't prevent GC
let cached = new WeakRef(loadHeavyObject());

function getCached() {
  const obj = cached.deref();   // may return undefined if GC collected it
  if (obj) return obj;

  // Object was collected — reload
  const fresh = loadHeavyObject();
  cached = new WeakRef(fresh);
  return fresh;
}

// FinalizationRegistry — run code when an object is garbage-collected
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Object with id ${heldValue} was garbage collected`);
});

function createTrackedResource(id) {
  const resource = { id, cleanup: true };
  registry.register(resource, id, resource);
  return resource;
}

// ⚠️ FinalizationRegistry is NON-DETERMINISTIC.
// It's for observability, NOT for critical cleanup.
// Use try/finally and AbortController for guaranteed cleanup.
⚠️

Don't use WeakRef for normal caching. Garbage collection timing is unpredictable — your "cache" may lose entries at any moment, and the process may never collect anything under memory pressure. Use an LRU cache with a size limit for real caching needs (like lru-cache).

10 · BigInt & Numeric Precision

JavaScript numbers are IEEE-754 doubles. They can represent integers exactly up to 253 − 1 (about 9 quadrillion). Beyond that, you get silent precision loss. That's fine for most business logic, but disastrous for things like financial IDs, Twitter snowflakes, big hashes, and cryptocurrency.

bigint.js JavaScript
// 💥 The classic precision loss
console.log(9007199254740993);  // 9007199254740992 😱 (last digit lost!)
console.log(0.1 + 0.2);              // 0.30000000000000004

// ✅ BigInt for precise integer arithmetic
const big = 9007199254740993n;   // note the 'n' suffix
console.log(big);                     // 9007199254740993n ✅

const max = BigInt.MAX_SAFE_INTEGER;
console.log(typeof big);              // 'bigint'
console.log(big + 1n);                // 9007199254740994n

// Conversions
BigInt('9007199254740993');       // 9007199254740993n
Number(9007199254740993n);       // 9007199254740992 (lossy!)

// ⚠️ Cannot mix BigInt and Number in arithmetic
// console.log(1n + 1);  // TypeError: Cannot mix BigInt and other types
console.log(1n + 1n);                 // 2n  — must use BigInt literals

// Division truncates (no fractions)
console.log(7n / 2n);                   // 3n

Real Backend Use — Snowflake IDs

Twitter/X-style snowflake IDs are 64-bit integers. JavaScript number cannot represent them exactly — a common bug when consuming Twitter API or Discord API. Use BigInt or store IDs as strings.

snowflake.js JavaScript
// Snowflake structure: 41 bits timestamp + 10 bits machine + 12 bits sequence
const EPOCH = 1288834974657n;   // Twitter epoch

function extractTimestamp(snowflake) {
  const id = BigInt(snowflake);
  const timestamp = (id >> 22n) + EPOCH;
  return new Date(Number(timestamp));
}

function extractMachine(snowflake) {
  const id = BigInt(snowflake);
  return Number((id >> 12n) & 0x3FFn);
}

function extractSequence(snowflake) {
  const id = BigInt(snowflake);
  return Number(id & 0xFFFn);
}

// Usage
const tweetId = '1234567890123456789';
console.log(extractTimestamp(tweetId));  // tweet creation date
console.log(extractMachine(tweetId));    // 0-1023
console.log(extractSequence(tweetId));   // 0-4095

// Important: keep IDs as strings in JSON to preserve precision
// JSON.stringify({ id: 1234567890123456789n }) throws TypeError
const payload = { id: tweetId.toString() };  // store as string

Numeric Separators — Readability Boost

numeric-separators.js JavaScript
// Underscore separators — visual grouping, ignored by the engine
const a = 1_000_000;            // 1000000
const b = 1_000_000_000;        // 1000000000
const c = 0xFF_FF_FF;            // 16777215
const d = 0b1010_0001;          // 161
const e = 1_000_000n;           // 1000000n (BigInt)

// Great for time and byte constants
const MS_PER_SECOND = 1_000;
const MS_PER_MINUTE = 60_000;
const MS_PER_HOUR = 3_600_000;
const MS_PER_DAY = 86_400_000;

const MAX_FILE_SIZE = 10_485_760;  // 10 MB in bytes

11 · Modern Array & String Methods

Every year, JavaScript gains a few more array and string methods that replace hand-rolled helpers. Here are the ones you should actually use in 2026.

Array.at() — The End of arr[arr.length - 1]

Old const last = arr[arr.length - 1];
New const last = arr.at(-1);

.at() accepts negative indices, giving Python-like behavior for the "Nth from end" access pattern. Works on strings, arrays, and typed arrays.

findLast & findLastIndex

find-last.js JavaScript
const logs = [
  { level: 'info', msg: 'started' },
  { level: 'error', msg: 'db timeout' },
  { level: 'info', msg: 'retry' },
  { level: 'error', msg: 'queue full' },
  { level: 'info', msg: 'completed' }
];

// Find the LAST error (most recent in chronological order)
const lastError = logs.findLast((l) => l.level === 'error');
// { level: 'error', msg: 'queue full' }

const lastErrorIdx = logs.findLastIndex((l) => l.level === 'error');
// 3

Object.groupBy & Map.groupBy — Data Shaping Made Easy

groupby.js JavaScript
const orders = [
  { id: 1, status: 'pending', amount: 50 },
  { id: 2, status: 'paid',    amount: 100 },
  { id: 3, status: 'pending', amount: 75 },
  { id: 4, status: 'paid',    amount: 200 },
  { id: 5, status: 'shipped', amount: 30 }
];

// Object.groupBy — returns plain object keyed by callback result
const byStatus = Object.groupBy(orders, (o) => o.status);
// {
//   pending: [{ id: 1, ... }, { id: 3, ... }],
//   paid:    [{ id: 2, ... }, { id: 4, ... }],
//   shipped: [{ id: 5, ... }]
// }

// Compute totals per status
const totals = {};
for (const [status, items] of Object.entries(byStatus)) {
  totals[status] = items.reduce((sum, o) => sum + o.amount, 0);
}
// { pending: 125, paid: 300, shipped: 30 }

// Map.groupBy — when you need object keys (not just strings)
const byDate = Map.groupBy(events, (e) => e.date);

String.replaceAll, matchAll, and Other Gems

string-methods.js JavaScript
const template = 'Hello {{name}}, your order {{orderId}} is ready.';

// replaceAll — replaces EVERY occurrence (no more /g regex)
const rendered = template
  .replaceAll('{{name}}', 'Alice')
  .replaceAll('{{orderId}}', '#12345');
// 'Hello Alice, your order #12345 is ready.'

// matchAll — returns iterator of all matches (not just first)
const log = '[2026-01-01] INFO ok [2026-01-02] WARN slow [2026-01-03] ERROR fail';
const pattern = /\[(\d{4}-\d{2}-\d{2})\]\s+(\w+)/g;

for (const match of log.matchAll(pattern)) {
  const [, date, level] = match;
  console.log(`${date} → ${level}`);
}

// String.padStart / padEnd — useful for formatting
const id = 42;
id.toString().padStart(6, '0');   // '000042'
'file'.padEnd(10, '.');           // 'file......'

// String.trimStart / trimEnd — trim one side only
'  hello  '.trimStart();   // 'hello  '
'  hello  '.trimEnd();     // '  hello'

// String.includes, startsWith, endsWith — modern search
'user@example.com'.includes('@');        // true
'Bearer token123'.startsWith('Bearer '); // true
'file.tar.gz'.endsWith('.gz');          // true

More Useful Additions

Method What It Does Backend Use Case
Array.prototype.flat() Flattens nested arrays Merging results from multiple sources
Array.prototype.flatMap() Map + flatten in one pass Transforming 1-to-many relationships
Array.prototype.includes() Boolean membership check Permission checks, feature gates
Object.entries() Array of [key, value] pairs Iterating config objects
Object.fromEntries() Build object from key-value pairs Array transformations that preserve keys
structuredClone() True deep clone with cycles Snapshotting state before mutation
crypto.randomUUID() RFC 4122 UUID v4 Request IDs, correlation IDs, primary keys

12 · Error.cause & Structured Errors

Before ES2022, when you caught an error and re-threw a higher-level one, you lost the original stack trace. You had to invent your own convention (err.originalError = …). Error.cause fixed this properly.

error-cause.js JavaScript
class DatabaseError extends Error {
  constructor(message, cause) {
    super(message, { cause });
    this.name = 'DatabaseError';
  }
}

class ServiceError extends Error {
  constructor(message, cause) {
    super(message, { cause });
    this.name = 'ServiceError';
  }
}

async function loadUserProfile(userId) {
  try {
    return await db.query('SELECT * FROM users WHERE id = ?', [userId]);
  } catch (err) {
    // Preserve the original error as the cause
    throw new DatabaseError('Failed to load user profile', err);
  }
}

async function getUserDashboard(userId) {
  try {
    return await loadUserProfile(userId);
  } catch (err) {
    throw new ServiceError('Could not build dashboard', err);
  }
}

// Full error chain is preserved
try {
  await getUserDashboard(42);
} catch (err) {
  console.log(err.message);              // 'Could not build dashboard'
  console.log(err.cause.message);        // 'Failed to load user profile'
  console.log(err.cause.cause.message);  // the original DB error

  // Walk the chain
  let current = err;
  while (current) {
    console.log(`${current.name}: ${current.message}`);
    current = current.cause;
  }
}

AggregateError — Multiple Errors at Once

aggregate-error.js JavaScript
// AggregateError — thrown by Promise.any when all fail
try {
  await Promise.any([
    fetch('https://a.example.com'),
    fetch('https://b.example.com'),
    fetch('https://c.example.com')
  ]);
} catch (err) {
  if (err instanceof AggregateError) {
    console.log(`All ${err.errors.length} sources failed:`);
    err.errors.forEach((e, i) => console.log(`  [${i}] ${e.message}`));
  }
}

// Creating your own AggregateError
function validateBatch(items) {
  const errors = [];
  items.forEach((item, i) => {
    if (!item.name) errors.push(new Error(`Item ${i}: name missing`));
    if (!item.email) errors.push(new Error(`Item ${i}: email missing`));
  });

  if (errors.length > 0) {
    throw new AggregateError(errors, 'Batch validation failed');
  }
}

13 · Production: Cache, DI, Event Bus

Let's combine everything into three production patterns that use the modern syntax you just learned. Each is real code you can drop into a Node.js service today.

Pattern 1 — TTL Cache with WeakMap Metadata

ttl-cache.js JavaScript
export class TtlCache {
  #store = new Map();
  #meta = new WeakMap();
  #defaultTtlMs;
  #maxSize;

  constructor({ defaultTtlMs = 60_000, maxSize = 1000 } = {}) {
    this.#defaultTtlMs = defaultTtlMs;
    this.#maxSize = maxSize;
  }

  set(key, value, ttlMs = this.#defaultTtlMs) {
    if (this.#store.size >= this.#maxSize && !this.#store.has(key)) {
      const oldestKey = this.#store.keys().next().value;
      this.delete(oldestKey);
    }
    this.#store.set(key, value);
    const expiresAt = Date.now() + ttlMs;
    this.#meta.set(value, { expiresAt });  // weak ref to value
    return this;
  }

  get(key) {
    const value = this.#store.get(key);
    if (value === undefined) return undefined;

    const meta = this.#meta.get(value);
    if (meta && Date.now() > meta.expiresAt) {
      this.delete(key);
      return undefined;
    }
    return value;
  }

  async getOrLoad(key, loader, ttlMs) {
    const cached = this.get(key);
    if (cached !== undefined) return cached;

    const loaded = await loader();
    this.set(key, loaded, ttlMs);
    return loaded;
  }

  delete(key) {
    this.#store.delete(key);
    return this;
  }

  clear() { this.#store.clear(); }

  get size() { return this.#store.size; }
}

// Usage
const cache = new TtlCache({ defaultTtlMs: 30_000, maxSize: 500 });

const user = await cache.getOrLoad(
  `user:${userId}`,
  () => db.findUser(userId),
  60_000
);

Pattern 2 — Dependency Injection via Map

container.js JavaScript
export class Container {
  #factories = new Map();
  #singletons = new Map();

  register(name, factory) {
    this.#factories.set(name, factory);
    return this;
  }

  singleton(name, factory) {
    this.#factories.set(name, () => {
      if (!this.#singletons.has(name)) {
        this.#singletons.set(name, factory(this));
      }
      return this.#singletons.get(name);
    });
    return this;
  }

  resolve(name) {
    const factory = this.#factories.get(name);
    if (!factory) throw new Error(`No provider for ${name}`);
    return factory(this);
  }
}

// Wire it up
const container = new Container();

container
  .singleton('db', () => new DbPool(config.dbUrl))
  .singleton('userRepo', (c) => new UserRepo(c.resolve('db')))
  .singleton('userService', (c) => new UserService(c.resolve('userRepo')))
  .register('userController', (c) => new UserController(c.resolve('userService')));

const controller = container.resolve('userController');

Pattern 3 — Type-Safe Event Bus

event-bus.js JavaScript
export class EventBus {
  #listeners = new Map();
  #onceListeners = new WeakMap();  // per-listener metadata

  on(event, handler) {
    if (!this.#listeners.has(event)) {
      this.#listeners.set(event, new Set());
    }
    this.#listeners.get(event).add(handler);

    // Return an unsubscribe function — modern idiom
    return () => this.off(event, handler);
  }

  once(event, handler) {
    const wrapper = async (...args) => {
      this.off(event, wrapper);
      await handler(...args);
    };
    return this.on(event, wrapper);
  }

  off(event, handler) {
    this.#listeners.get(event)?.delete(handler);
    if (this.#listeners.get(event)?.size === 0) {
      this.#listeners.delete(event);
    }
    return this;
  }

  async emit(event, payload) {
    const handlers = this.#listeners.get(event);
    if (!handlers) return [];

    const results = await Promise.allSettled(
      [...handlers].map((h) => h(payload))
    );

    // Log any handler failures without breaking others
    results.forEach((r, i) => {
      if (r.status === 'rejected') {
        console.error(`Event handler #${i} for "${event}" failed:`, r.reason);
      }
    });

    return results;
  }

  clear() { this.#listeners.clear(); }
}

// Usage
const bus = new EventBus();

const unsubscribe = bus.on('user.registered', async ({ userId, email }) => {
  await sendWelcomeEmail(email);
});

bus.on('user.registered', async ({ userId }) => {
  await provisionDefaults(userId);
});

await bus.emit('user.registered', { userId: 42, email: 'a@b.com' });

14 · AI Corner: CJS→ESM Migration & Modern Refactoring

AI shines brightest at large-scale mechanical migrations. Modernizing a legacy CommonJS codebase to ESM is exactly the kind of tedious, error-prone task where AI saves days.

📦

CJS → ESM Conversion

"Convert this CommonJS file to ESM. Handle default export interop correctly. Show import.meta equivalents for __dirname."

🎯

Optional Chaining Audit

"Refactor every `a && a.b && a.b.c` pattern in this file to optional chaining. Do not change behavior for numeric or string falsy values."

♻️

Dynamic Import Conversion

"Convert the top 5 heaviest static imports in this file to dynamic imports. Explain which routes benefit most."

📉

Memory Leak Detection

"Find all Map/Set usages in this code that should be WeakMap/WeakSet. Explain each conversion."

🔢

BigInt Migration

"Identify every place where a large integer could lose precision. Show BigInt conversions with JSON serialization handling."

🪄

Generator Extraction

"Convert this array-based loop into an async generator that streams results. Preserve error semantics."

🤖

The best migration prompt I've seen: "Migrate this Node.js service from CommonJS to ESM. Show me a before/after diff for each file. List every edge case: __dirname, __filename, require.main, JSON imports, native module interop. Then walk through the package.json changes needed for a hybrid transition." You get a migration plan in 30 seconds that would take 3 hours to research manually.

⚠️

What AI gets wrong in ESM migrations: it often forgets file extensions in relative imports ('./x' must become './x.js' in ESM), uses require() to load JSON without checking for import assertions, and mixes module.exports with export default. Always test on a branch first.

15 · Interactive Knowledge Check

Twelve questions covering every modern feature in this article. Each maps to a production scenario you'll hit within your first week of writing modern JavaScript.

🧠

Part 4 — Modern Syntax & Modules Quiz

Twelve questions. No guessing.
Score: 0 / 12

16 · Cheat Sheet & What's Next

Modern Syntax — One-Page Summary

Feature One-Line Rule
?.Short-circuits on null/undefined only — not on 0/''.
?.[]Same for dynamic keys.
?.()Optional method call — no TypeError if missing.
??Fallback only for nullish. Prefer over || for config.
??=Assign if currently nullish. Perfect for lazy init.
||=Assign if currently falsy (any falsy value).
&&=Assign only if currently truthy.
importStatic, hoisted, resolved at parse time.
import()Dynamic, returns a promise. For optional/lazy deps.
Top-level awaitOnly in ESM. Use for critical init, not optional data.
GeneratorsLazy sequences. Constant memory. Async generators for streaming.
WeakMapObject keys. GC-friendly. Use for metadata and private state.
WeakSetTrack object membership without preventing GC.
WeakRefAdvanced caching only. Non-deterministic. Rarely needed.
BigIntPrecise integers beyond 253. Can't mix with Number.
Numeric separators1_000_000 — for readability.
.at()Negative indices work: arr.at(-1).
findLastSearch from the end without reversing.
Object.groupByGroup arrays into objects — replaces reduce boilerplate.
Error.causePreserve error chains with new Error(msg, { cause }).
AggregateErrorMultiple errors bundled — used by Promise.any.

Do / Don't — Modern JavaScript Edition

✅ DO

  • Default to ?? over || for defaults.
  • Use ?. to eliminate defensive && chains.
  • Enable ESM in new projects with "type": "module".
  • Use dynamic imports for optional heavy dependencies.
  • Reach for generators when processing large datasets.
  • Attach request metadata with WeakMap — no leaks.
  • Store BigInt IDs as strings in JSON.
  • Preserve error chains with { cause }.
  • Use numeric separators for readability.
  • Prefer .at(-1), .findLast(), Object.groupBy().

❌ DON'T

  • Don't use || for defaults where 0/'' are valid.
  • Don't mix require() and import in the same file.
  • Don't omit .js extensions in ESM imports.
  • Don't overuse dynamic imports — bundlers get confused.
  • Don't put top-level await in every module.
  • Don't use a regular Map for object-keyed metadata (leaks).
  • Don't use WeakRef for normal caching.
  • Don't mix BigInt and Number in arithmetic.
  • Don't lose error context by re-throwing without cause.
  • Don't fight modern syntax — embrace it.

What's Coming in Part 5

Part 5 is about the browser and DOM — the features that matter when your JavaScript has to actually run in a page:

  • The DOM tree and the rendering pipeline — layout, paint, composite.
  • Event phases: capture, target, bubble — and how to use them well.
  • Event delegation patterns (and when they hurt).
  • MutationObserver, IntersectionObserver, ResizeObserver.
  • requestAnimationFrame and the 60-fps budget.
  • Memory leaks in SPAs and how to hunt them with DevTools.
  • The Performance API — measuring what actually matters.
  • AI-assisted performance audits.
🎯

Practice before Part 5: migrate one small module in your codebase from CommonJS to ESM. Then convert one long synchronous loop into a generator pipeline. Then find one Map that should be a WeakMap. Each exercise is small — 15 minutes each — but they'll all become muscle memory you carry for the rest of your career.


Part 4 of 7 · JavaScript for Backend Developers · FreeLearning365.com

🌟 Continue Learning on FreeLearning365

Free tools, tutorials, and question banks for developers, students, and professionals.

🌍 FreeLearning365.com — Your gateway to free learning, tools & resources.

Part 4 of 7 · JavaScript for Backend Developers · © 2026 FreeLearning365


🚀 JavaScript for Backend Developers
🗺️ The Complete 7-Part Journey
Navigate anywhere in the series
Learning Path Part 1 of 7
🏆 Part 7 → AI + Observability + Production + 3 Capstone Projects

Post a Comment

0 Comments