Skip to content

JS + React + CSS Deep Dive — Salesforce SMTS

This file is the frontend fundamentals cram for Salesforce SMTS. Event loop, closures, currying, polyfills, async, React hooks, browser APIs, and — most importantly — CSS performance. Several of these topics have been asked verbatim in recent loops.

Salesforce-Confirmed Topics (call-outs)

Pin these to the top of your memory before the interview:

  • Which CSS properties to avoid animating and why — Nov 2025 report, asked verbatim. See Section 8.
  • FPS, measurement, and reaching 60 fps — same report.
  • Currying — asked as a live-coding warm-up in Dec 2025 loop. See Section 3.
  • Event loop trace — macrotask vs microtask ordering. See Section 1.
  • Polyfills: bind, Promise.all, Array.prototype.map. See Section 6.

Quick Reference Cheat Sheet

TopicKey fact
GPU-accelerated propertiestransform, opacity — no Layout, no Paint
Layout-triggering propertieswidth, height, top, left, margin, padding, border, font-size
Paint-triggering propertiescolor, background, box-shadow, border-radius, visibility
Frame budget16.67 ms at 60 fps; 8.33 ms at 120 fps
Render pipelineJS → Style → Layout → Paint → Composite
MicrotaskPromise.then, queueMicrotask, MutationObserver
MacrotasksetTimeout, setInterval, postMessage, I/O, UI events
requestAnimationFrameRuns before next paint, not in either queue
requestIdleCallbackRuns when browser is idle; low-priority
Event delegationAttach one listener at ancestor; use event.target
Event phasesCapture (down) → Target → Bubble (up)
DebounceFire once after quiet period; use for search inputs
ThrottleFire at most once per interval; use for scroll / resize

Section 1: Event Loop Deep Dive

JavaScript runs single-threaded with an event loop. Understanding the ordering is table stakes at SMTS.

The Model

  • Call stack — synchronous JS executes here frame by frame.
  • Heap — object memory.
  • Task queues — at least two: macrotask queue and microtask queue.
  • Render steps — after each macrotask the browser may run style → layout → paint if a frame is due.

Event Loop Algorithm (simplified)

text
while (true) {
  task = pickOneMacrotask();            // 1. pick one macrotask
  run(task);                             //    run it to completion
  while (microtaskQueue.nonEmpty()) {   // 2. drain all microtasks
    run(microtaskQueue.dequeue());
  }
  if (frameDue()) {                      // 3. if it's time to paint
    runAnimationFrameCallbacks();        //    rAF callbacks run here
    runResizeObservers();
    runIntersectionObservers();
    performLayoutPaintComposite();
  }
  if (browserIsIdle()) runIdleCallbacks();
}

Key rule: microtasks drain after every macrotask, and also after every rAF callback. This is why a Promise.then scheduled inside a setTimeout runs before the next setTimeout fires.

Macrotasks vs Microtasks

SourceQueue
setTimeout, setIntervalMacrotask
setImmediate (Node)Macrotask
MessageChannel.postMessageMacrotask
requestAnimationFrameNeither — separate rAF callback list
I/O, UI eventsMacrotask
Promise.then / catch / finallyMicrotask
queueMicrotask(fn)Microtask
MutationObserverMicrotask
async function continuationMicrotask

Why Promise.then Fires Before setTimeout(0)

typescript
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

Output: A D C B.

Trace:

  1. Sync frame runs "A", schedules setTimeout (macrotask), schedules .then (microtask), runs "D".
  2. Call stack empties. Microtask queue drains — "C" prints.
  3. Next macrotask picked — the timer callback — "B" prints.

queueMicrotask, requestAnimationFrame, requestIdleCallback

  • queueMicrotask(fn) — schedule fn on the microtask queue explicitly. Useful to defer work while still running before the next macrotask / paint.
  • requestAnimationFrame(fn) — runs just before the next paint. Best for animation work that needs to read layout or apply transforms.
  • requestIdleCallback(fn, { timeout }) — runs when the browser has spare time between frames. Good for analytics, low-priority logging. Not supported in Safari; polyfill with setTimeout fallback.

Trace Example 1 — rAF + microtask interleave

typescript
console.log("1");
setTimeout(() => console.log("2"));
requestAnimationFrame(() => console.log("3"));
Promise.resolve().then(() => console.log("4"));
queueMicrotask(() => console.log("5"));
console.log("6");

Likely output: 1 6 4 5 2 3 — but 3 could come before 2 if a frame is due. Exact rAF vs timer ordering depends on frame timing; the only guarantee is that microtasks (4, 5) drain before any of 2 or 3.

Trace Example 2 — async/await

typescript
async function run() {
  console.log("start");
  await Promise.resolve();
  console.log("after await");
}
run();
console.log("sync after");

Output: start sync after after await.

await suspends the function; the rest becomes a microtask. Sync code that follows the run() call finishes first, then the microtask drain runs the continuation.

Trace Example 3 — nested Promises

typescript
Promise.resolve().then(() => {
  console.log("A");
  Promise.resolve().then(() => console.log("B"));
});
Promise.resolve().then(() => console.log("C"));

Output: A C B. The first then adds a new microtask after "A", but "C" was already in the queue so it runs first.

Trace Example 4 — setTimeout 0 starvation

typescript
function loop(i: number) {
  if (i === 0) return;
  Promise.resolve().then(() => loop(i - 1));
}
loop(1e6);
setTimeout(() => console.log("timer"), 0);

The timer can starve for a long time because microtasks keep getting re-queued. This is a common real bug in recursive Promise patterns.

Browser Render Pipeline Relative to Event Loop

  • Only one frame paints per ~16.67 ms.
  • Inside a frame: pick one macrotask → drain microtasks → rAF callbacks → style/layout/paint/composite.
  • Long tasks (>50 ms) starve the frame — INP suffers, users perceive jank.
  • Use requestIdleCallback or schedule via MessageChannel.postMessage (Scheduler.yield shim) to break work into frames.

Section 2: Closures

Lexical Scoping

A function captures variables from the scope it was defined in, not where it's called.

typescript
function makeCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    value: () => count,
  };
}
const c = makeCounter();
c.increment();
c.increment();
console.log(c.value());                 // 2

count lives on the closure, not the global scope. Each makeCounter() call creates a fresh one.

Practical Uses

  • Private state — encapsulation without classes.
  • Memoization — cache map lives in closure.
  • Factoriesmultiplier(n) returns a function multiplying by n.
  • Currying — next section.
  • Event handler identity — capture this-free references.

once(fn) — run a function at most once

typescript
function once<TArgs extends unknown[], TResult>(
  fn: (...args: TArgs) => TResult,
): (...args: TArgs) => TResult | undefined {
  let called = false;
  let value: TResult | undefined;
  return (...args: TArgs) => {
    if (called) return value;
    called = true;
    value = fn(...args);
    return value;
  };
}

memoize(fn) — single-argument version

typescript
function memoize<T, R>(fn: (arg: T) => R, keyFn: (arg: T) => string | number = (a) => a as unknown as string): (arg: T) => R {
  const cache = new Map<string | number, R>();
  return (arg: T) => {
    const key = keyFn(arg);
    if (cache.has(key)) return cache.get(key)!;
    const result = fn(arg);
    cache.set(key, result);
    return result;
  };
}

For multi-argument memoization, stringify the argument tuple or use a nested Map.

Factory Example

typescript
function prefixer(prefix: string): (s: string) => string {
  return (s) => `${prefix}${s}`;
}
const errorPrefix = prefixer("[error] ");
errorPrefix("oops");                    // "[error] oops"

Common Gotcha — var in loop

typescript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);  // 3, 3, 3
}
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);  // 0, 1, 2
}

var is function-scoped; let creates a new binding per iteration. Interviewers love this one.


Section 3: Currying — Confirmed Salesforce Question

addThreeNumbers(a)(b)(c) — direct implementation

typescript
function addThreeNumbers(a: number): (b: number) => (c: number) => number {
  return (b: number) => (c: number) => a + b + c;
}
addThreeNumbers(1)(2)(3);                // 6

Generic curry(fn, arity)

typescript
type AnyFn = (...args: unknown[]) => unknown;

function curry<F extends AnyFn>(fn: F, arity: number = fn.length): any {
  const inner = (args: unknown[]) =>
    args.length >= arity
      ? fn(...args)
      : (...next: unknown[]) => inner([...args, ...next]);
  return inner([]);
}

const add = (a: number, b: number, c: number) => a + b + c;
const curried = curry(add);
curried(1)(2)(3);                        // 6
curried(1, 2)(3);                        // 6
curried(1)(2, 3);                        // 6
curried(1, 2, 3);                        // 6

A strict version supporting partial application from the left only:

typescript
function curryStrict<F extends AnyFn>(fn: F): any {
  return function curried(...args: unknown[]): any {
    return args.length >= fn.length
      ? fn(...args)
      : (...more: unknown[]) => curried(...args, ...more);
  };
}

Partial Application (a cousin of currying)

typescript
function partial<F extends AnyFn>(fn: F, ...preset: unknown[]): AnyFn {
  return (...rest: unknown[]) => fn(...preset, ...rest);
}
const add10 = partial(add, 10);
add10(5, 6);                             // 21

Difference: curry returns functions until arity is met; partial binds some arguments and returns a function that still accepts multiple.

Curry with Defaults

If fn.length counts only required params, defaults throw off the arity. Pass arity explicitly:

typescript
const greet = (greeting: string, name = "world") => `${greeting}, ${name}`;
const cGreet = curry(greet, 1);          // treat as 1-arg
cGreet("hi");                            // "hi, world"

Follow-up: infinite / lazy curry

Interviewers sometimes ask: "Make sum(1)(2)(3)() return 6." Use a function that is also callable as a number via valueOf:

typescript
function lazySum(n: number): any {
  const fn: any = (m?: number) => (m === undefined ? n : lazySum(n + m));
  fn.valueOf = () => n;
  return fn;
}
+lazySum(1)(2)(3);                       // 6 (via valueOf coercion)

Show both the curried-arity version and the lazy version — the choice signals maturity.


Section 4: Debounce and Throttle

When to Use Each

  • Debounce — fire once after the user pauses. Use for search input, resize handlers that re-render layout, autosave.
  • Throttle — fire at most once per interval during continuous events. Use for scroll, mousemove, gamepad polling.

Debounce with Leading / Trailing Edge + Cancel

typescript
type DebouncedFn<F extends (...args: any[]) => any> = ((...args: Parameters<F>) => void) & {
  cancel: () => void;
  flush: () => void;
};

function debounce<F extends (...args: any[]) => any>(
  fn: F,
  wait: number,
  options: { leading?: boolean; trailing?: boolean } = { trailing: true },
): DebouncedFn<F> {
  let timer: ReturnType<typeof setTimeout> | null = null;
  let lastArgs: Parameters<F> | null = null;
  let lastThis: unknown = null;

  const invoke = () => {
    if (lastArgs) fn.apply(lastThis, lastArgs);
    lastArgs = null;
  };

  const debounced = function (this: unknown, ...args: Parameters<F>) {
    const callNow = options.leading && timer === null;
    lastArgs = args;
    lastThis = this;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (options.trailing && lastArgs) invoke();
    }, wait);
    if (callNow) invoke();
  } as DebouncedFn<F>;

  debounced.cancel = () => {
    if (timer) clearTimeout(timer);
    timer = null;
    lastArgs = null;
  };
  debounced.flush = () => {
    if (timer) {
      clearTimeout(timer);
      timer = null;
      invoke();
    }
  };

  return debounced;
}

Throttle with Leading / Trailing

typescript
function throttle<F extends (...args: any[]) => any>(
  fn: F,
  wait: number,
  options: { leading?: boolean; trailing?: boolean } = { leading: true, trailing: true },
): DebouncedFn<F> {
  let timer: ReturnType<typeof setTimeout> | null = null;
  let lastCall = 0;
  let lastArgs: Parameters<F> | null = null;
  let lastThis: unknown = null;

  const invoke = () => {
    lastCall = Date.now();
    if (lastArgs) fn.apply(lastThis, lastArgs);
    lastArgs = null;
  };

  const throttled = function (this: unknown, ...args: Parameters<F>) {
    const now = Date.now();
    if (lastCall === 0 && options.leading === false) lastCall = now;
    const remaining = wait - (now - lastCall);
    lastArgs = args;
    lastThis = this;

    if (remaining <= 0) {
      if (timer) { clearTimeout(timer); timer = null; }
      invoke();
    } else if (!timer && options.trailing !== false) {
      timer = setTimeout(() => {
        timer = null;
        lastCall = options.leading === false ? 0 : Date.now();
        invoke();
      }, remaining);
    }
  } as DebouncedFn<F>;

  throttled.cancel = () => {
    if (timer) clearTimeout(timer);
    timer = null; lastCall = 0; lastArgs = null;
  };
  throttled.flush = () => {
    if (timer) { clearTimeout(timer); timer = null; invoke(); }
  };

  return throttled;
}

React-Specific Hooks

typescript
import { useEffect, useMemo, useRef, useState } from "react";

export function useDebouncedValue<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

export function useThrottledCallback<F extends (...args: any[]) => any>(
  fn: F,
  wait: number,
): F {
  const fnRef = useRef(fn);
  useEffect(() => { fnRef.current = fn; }, [fn]);

  const throttled = useMemo(() => throttle((...args: Parameters<F>) => fnRef.current(...args), wait), [wait]);
  useEffect(() => () => throttled.cancel(), [throttled]);
  return throttled as unknown as F;
}

Two subtle points often probed:

  1. Wrap fn in a ref so useMemo doesn't have to re-create the throttled function every render.
  2. Cancel pending calls on unmount to avoid setState-after-unmount warnings.

Section 5: Promise / Async

Promise States

A Promise is in one of pending, fulfilled, rejected. Once settled, it's immutable. Handlers registered via .then always run asynchronously (microtask), even if the Promise is already resolved.

Chaining

typescript
fetch("/api")
  .then((r) => r.json())
  .then((data) => process(data))
  .catch((e) => log(e))
  .finally(() => hideSpinner());

Each .then returns a new Promise. Returning a value from a handler resolves the downstream Promise; throwing rejects it; returning a Promise forwards it.

async/await Under the Hood

typescript
async function foo() {
  const x = await fetchX();
  return x + 1;
}

Desugars to:

typescript
function foo() {
  return Promise.resolve().then(() => fetchX()).then((x) => x + 1);
}

More accurately: the function body is a state machine. await suspends; the continuation runs as a microtask when the awaited Promise settles.

Promise combinators

CombinatorResolves whenRejects when
Promise.all([p1, p2])all resolved → [r1, r2]any rejected → first rejection
Promise.allSettled([p1, p2])all settled → `[{status, valuereason}]`
Promise.race([p1, p2])first settles (resolve or reject)same
Promise.any([p1, p2])first resolvedall rejected → AggregateError

Polyfill — Promise.all

typescript
function promiseAll<T>(promises: Iterable<Promise<T>>): Promise<T[]> {
  return new Promise((resolve, reject) => {
    const arr = Array.from(promises);
    if (arr.length === 0) return resolve([]);
    const out: T[] = new Array(arr.length);
    let done = 0;
    arr.forEach((p, i) => {
      Promise.resolve(p).then(
        (v) => { out[i] = v; if (++done === arr.length) resolve(out); },
        reject,
      );
    });
  });
}

Polyfill — Promise.allSettled

typescript
type Settled<T> = { status: "fulfilled"; value: T } | { status: "rejected"; reason: unknown };

function promiseAllSettled<T>(promises: Iterable<Promise<T>>): Promise<Settled<T>[]> {
  const arr = Array.from(promises);
  return Promise.all(arr.map((p) =>
    Promise.resolve(p).then(
      (value) => ({ status: "fulfilled" as const, value }),
      (reason) => ({ status: "rejected" as const, reason }),
    ),
  ));
}

Polyfill — Promise.any

typescript
function promiseAny<T>(promises: Iterable<Promise<T>>): Promise<T> {
  return new Promise((resolve, reject) => {
    const arr = Array.from(promises);
    if (arr.length === 0) return reject(new AggregateError([], "All promises were rejected"));
    const errors: unknown[] = new Array(arr.length);
    let rejected = 0;
    arr.forEach((p, i) => {
      Promise.resolve(p).then(resolve, (err) => {
        errors[i] = err;
        if (++rejected === arr.length) reject(new AggregateError(errors, "All promises were rejected"));
      });
    });
  });
}

Section 6: Polyfills

Function.prototype.bind

typescript
declare global {
  interface Function {
    myBind<T>(this: T, thisArg: unknown, ...presets: unknown[]): T;
  }
}

Function.prototype.myBind = function (thisArg: unknown, ...presets: unknown[]) {
  const fn = this as (...args: unknown[]) => unknown;
  if (typeof fn !== "function") throw new TypeError("myBind called on non-function");
  function bound(this: unknown, ...rest: unknown[]) {
    // if called with `new`, ignore thisArg (native bind spec)
    const isCtor = this instanceof bound;
    return fn.apply(isCtor ? this : thisArg, [...presets, ...rest]);
  }
  bound.prototype = Object.create(fn.prototype);
  return bound as any;
};

Function.prototype.call and apply

typescript
Function.prototype.myCall = function (thisArg: unknown, ...args: unknown[]) {
  const ctx = (thisArg ?? globalThis) as Record<string, unknown>;
  const key = Symbol("fn");
  ctx[key as any] = this as any;
  const result = (ctx[key as any] as any)(...args);
  delete ctx[key as any];
  return result;
};

Function.prototype.myApply = function (thisArg: unknown, args: unknown[] = []) {
  return (this as any).myCall(thisArg, ...args);
};

Array.prototype.map

typescript
Array.prototype.myMap = function <T, U>(this: T[], fn: (v: T, i: number, arr: T[]) => U, thisArg?: unknown): U[] {
  if (typeof fn !== "function") throw new TypeError("fn not callable");
  const out: U[] = new Array(this.length);
  for (let i = 0; i < this.length; i++) {
    if (i in this) out[i] = fn.call(thisArg, this[i], i, this);
  }
  return out;
};

Array.prototype.filter

typescript
Array.prototype.myFilter = function <T>(this: T[], pred: (v: T, i: number, arr: T[]) => boolean, thisArg?: unknown): T[] {
  const out: T[] = [];
  for (let i = 0; i < this.length; i++) {
    if (i in this && pred.call(thisArg, this[i], i, this)) out.push(this[i]);
  }
  return out;
};

Array.prototype.reduce

typescript
Array.prototype.myReduce = function <T, U>(this: T[], fn: (acc: U, v: T, i: number, arr: T[]) => U, init?: U): U {
  let acc: U;
  let startIdx = 0;
  if (arguments.length >= 2) {
    acc = init as U;
  } else {
    while (startIdx < this.length && !(startIdx in this)) startIdx++;
    if (startIdx >= this.length) throw new TypeError("Reduce of empty array with no initial value");
    acc = this[startIdx] as unknown as U;
    startIdx++;
  }
  for (let i = startIdx; i < this.length; i++) {
    if (i in this) acc = fn(acc, this[i], i, this);
  }
  return acc;
};

Object.assign

typescript
function myAssign<T extends object>(target: T, ...sources: object[]): T {
  if (target == null) throw new TypeError("Cannot convert undefined or null to object");
  const to = Object(target) as Record<string, unknown>;
  for (const src of sources) {
    if (src == null) continue;
    for (const key of Reflect.ownKeys(src)) {
      const desc = Object.getOwnPropertyDescriptor(src, key);
      if (desc?.enumerable) to[key as string] = (src as Record<string, unknown>)[key as string];
    }
  }
  return to as T;
}

Basic JSON.stringify

typescript
function myStringify(value: unknown): string {
  if (value === null) return "null";
  if (typeof value === "number") return Number.isFinite(value) ? String(value) : "null";
  if (typeof value === "boolean") return String(value);
  if (typeof value === "string") return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
  if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") return undefined as unknown as string;
  if (Array.isArray(value)) {
    return `[${value.map((v) => myStringify(v) ?? "null").join(",")}]`;
  }
  if (typeof value === "object") {
    const parts: string[] = [];
    for (const key of Object.keys(value as object)) {
      const v = (value as Record<string, unknown>)[key];
      const serialised = myStringify(v);
      if (serialised !== undefined) parts.push(`${myStringify(key)}:${serialised}`);
    }
    return `{${parts.join(",")}}`;
  }
  return "null";
}

Real JSON.stringify handles toJSON, replacer functions, indentation, and circular-ref errors — mention those even if you skip them.

flatten(arr, depth)

typescript
function flatten<T>(arr: unknown[], depth: number = 1): T[] {
  const out: T[] = [];
  const recur = (a: unknown[], d: number) => {
    for (const item of a) {
      if (Array.isArray(item) && d > 0) recur(item, d - 1);
      else out.push(item as T);
    }
  };
  recur(arr, depth);
  return out;
}

function flattenDeep<T>(arr: unknown[]): T[] { return flatten<T>(arr, Infinity); }

Iterative version using a stack avoids recursion limits on deep arrays.


Section 7: React Hooks

Rules of Hooks — and Why

  • Top-level only — no hooks inside loops, conditions, or nested functions.
  • Only from React functions — components or custom hooks.

Why: React stores hook state in a linked list keyed by call order per render. Skipping or inserting a hook breaks the index.

useMemo vs useCallback vs React.memo

ToolMemoisesUse when
useMemo(() => x, deps)ValueExpensive computation; referential stability of returned object
useCallback(fn, deps)Function referencePassing to memoised child or to a dep array
React.memo(Component)Re-render decisionPure child that receives the same props often

Warning: memoisation has a cost. Don't wrap everything. Rule of thumb: the work inside useMemo should be at least O(n) with small n, or the returned value should be passed to a React.memo child that would otherwise re-render expensively.

Example where useCallback is pointless:

typescript
const onClick = useCallback(() => doThing(), []);
<button onClick={onClick}>X</button>

<button> is a DOM element — it doesn't re-render based on prop identity. The useCallback adds cost for zero benefit.

useEffect Pitfalls

  1. Missing deps — causes stale closures. Lint with react-hooks/exhaustive-deps.
  2. Cleanup — always return a cleanup for subscriptions, timers, aborts.
  3. Race conditions — fetch on deps change without aborting.
typescript
useEffect(() => {
  let cancelled = false;
  const controller = new AbortController();
  fetch(`/api/${id}`, { signal: controller.signal })
    .then((r) => r.json())
    .then((data) => { if (!cancelled) setData(data); })
    .catch((e) => { if (e.name !== "AbortError" && !cancelled) setError(e); });
  return () => { cancelled = true; controller.abort(); };
}, [id]);
  1. Double invoke in Strict Mode (dev) — React 18 intentionally mounts, unmounts, and remounts components in development. Effects must be idempotent under this.
  2. setState in effect loop — unguarded setState inside an effect that depends on that state creates an infinite loop.

Custom Hook Patterns

Data fetcher with abort + cleanup:

typescript
function useAsyncData<T>(key: string, fetcher: (signal: AbortSignal) => Promise<T>) {
  const [state, setState] = useState<{ status: "idle" | "loading" | "ok" | "err"; data?: T; error?: Error }>({ status: "idle" });
  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading" });
    fetcher(controller.signal)
      .then((data) => setState({ status: "ok", data }))
      .catch((error) => {
        if (error.name === "AbortError") return;
        setState({ status: "err", error });
      });
    return () => controller.abort();
  }, [key]);
  return state;
}

Subscription:

typescript
function useOnlineStatus() {
  const [online, setOnline] = useState(() => navigator.onLine);
  useEffect(() => {
    const on = () => setOnline(true);
    const off = () => setOnline(false);
    window.addEventListener("online", on);
    window.addEventListener("offline", off);
    return () => {
      window.removeEventListener("online", on);
      window.removeEventListener("offline", off);
    };
  }, []);
  return online;
}

Section 8: CSS Performance — Confirmed Salesforce Topic

This section was asked verbatim in a Nov 2025 Salesforce SMTS Frontend round. Master it.

The Rendering Pipeline

Every visible change takes some subset of these steps:

  1. JavaScript — event handlers, timers, style changes.
  2. Style — recompute matched CSS rules for affected elements.
  3. Layout (aka Reflow) — compute geometry: size, position, flow.
  4. Paint — rasterise pixels for each layer.
  5. Composite — combine layers into the final image using the GPU.

A property change re-enters the pipeline at whichever step it affects. You want changes that enter as late as possible.

CSS Properties to Avoid Animating — and Why

Layout-triggering (worst) — trigger Layout → Paint → Composite every frame:

  • width, height, min-width, max-width, min-height, max-height
  • top, left, right, bottom
  • margin, padding, border-width
  • font-size, line-height, font-weight
  • display changes that affect flow
  • position changes
  • float

Animating width from 100px to 200px forces the browser to re-solve the layout of this element and every descendant and possibly siblings — then re-paint them — every frame. At 60 fps that's 60 layout passes per second on a potentially large tree.

Paint-triggering (better but still costly) — skip Layout but still Paint:

  • color
  • background, background-color, background-image
  • border-radius, border-color
  • box-shadow, text-shadow
  • visibility
  • outline

A color transition rasterises every affected glyph each frame. Cheaper than Layout but still touches the paint stage.

Composite-only (best — use for animations) — GPU-accelerated, no Layout or Paint:

  • transformtranslate, scale, rotate, skew, matrix
  • opacity
  • filter (on supported browsers, usually)

The element is promoted to its own compositor layer; the GPU applies the transform or opacity as a matrix multiplication. 60 fps is realistic on even modest hardware.

Rule: instead of left: 100px → 200px, use transform: translateX(0) → translateX(100px). Instead of width: 100px → 200px, use transform: scaleX(1) → scaleX(2) and anchor with transform-origin.

Example — the wrong way

css
.box {
  position: absolute;
  left: 0;
  transition: left 300ms ease;
}
.box.open { left: 240px; }              /* triggers Layout every frame */

Example — the right way

css
.box {
  transform: translateX(0);
  transition: transform 300ms ease;
  will-change: transform;
}
.box.open { transform: translateX(240px); }

FPS — What, How to Measure, How to Achieve 60

Frame budget: 1000 ms / 60 = 16.67 ms per frame. Inside that the browser must run JS, style, layout, paint, composite — you really have ~10 ms for your JS.

Measuring FPS:

  1. DevTools Performance panel — record an interaction. Look at the Frames lane: green bars are good frames, red are dropped. The main timeline shows Layout (purple), Paint (green), Composite (olive). A wall of purple means layout thrashing.
  2. Rendering tab → Frame Rendering Stats — live FPS meter overlay.
  3. Performance.now() loop with requestAnimationFrame:
typescript
let last = performance.now();
let frames = 0;
function tick(now: number) {
  frames += 1;
  if (now - last >= 1000) {
    console.log(`FPS: ${frames}`);
    frames = 0;
    last = now;
  }
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
  1. Web Vitals — INP (Interaction to Next Paint) — real-user metric. Good: under 200 ms.

Achieving 60 fps:

  • Animate transform and opacity only.
  • Use will-change: transform to hint the browser to promote to its own layer — but sparingly; every layer costs memory.
  • Avoid layout thrashing: batch DOM reads and writes (see Section 9).
  • Use content-visibility: auto on offscreen content to skip layout for hidden sections.
  • Use contain: layout paint to isolate a subtree from affecting the page.
  • rAF for animation timing instead of setInterval.
  • Debounce or throttle scroll handlers.
  • Prefer CSS transitions / animations over JS-driven ones — the compositor thread can run them even when main is blocked.

GPU Layers — will-change, transform: translateZ(0), contain

  • will-change: transform — declares your intent. Browser may promote the element to a layer. Don't apply to many elements; each layer uses GPU memory. Remove after animation ends if applied dynamically.
  • transform: translateZ(0) — legacy trick to force layer creation. Equivalent in effect to will-change in most browsers today.
  • contain: layout paint size — tells the browser this subtree is isolated; changes inside don't affect outside. Enables massive optimisations for long lists.
  • content-visibility: auto — hides offscreen subtrees from rendering until they approach the viewport. Massive win on long pages.

CSS Positioning and Rendering

PositionFlowContaining blockEffect
staticIn flowParentDefault; cheap
relativeIn flowParentStill affects siblings; offsets are render-time only
absoluteOut of flowNearest positioned ancestorDoesn't affect siblings; still triggers layout of container
fixedOut of flowViewportOften promoted to its own layer on scroll
stickyHybridScroll containerBrowser manages the transition; test on various browsers

Interview sound-bite: "position: absolute takes the element out of normal flow, so siblings lay out as if it weren't there. The element is positioned relative to the nearest ancestor with a non-static position — otherwise the initial containing block. It still triggers layout in the containing block when its geometry changes, but not in the unrelated subtree."

CSS Transitions vs Animations

  • Transition — from state A to state B when a property changes. Triggered by class toggle or style change.
  • Animation — keyframed, independent of a state change; can loop, reverse, stagger.

Use transitions for hover / click state changes; animations for looping UI (spinners, pulses) or multi-step motion.

Critical Rendering Path

  • Blocking resources: <link rel="stylesheet"> blocks render by default; sync <script> blocks parsing and render.
  • defer — download in parallel, execute after HTML parsing, in order.
  • async — download in parallel, execute as soon as available, out of order. Use only for analytics / independent scripts.
  • preload critical assets; preconnect to third-party origins you will fetch from.
  • Inline critical CSS for above-the-fold.
  • Split JS bundles; route-based code splitting.

Section 9: Browser / DOM

Event Delegation

Attach one listener at an ancestor, read event.target to identify the source. Useful for long lists, dynamic content.

typescript
document.querySelector("ul")!.addEventListener("click", (e) => {
  const li = (e.target as HTMLElement).closest("li");
  if (!li) return;
  handle(li.dataset.id!);
});

Saves memory, survives DOM churn, and leverages bubbling.

Event Phases

  1. Capture — from window down to target. Pass { capture: true } to listen here.
  2. Target — at the target element.
  3. Bubble — from target back up to window.

event.stopPropagation() halts propagation but not default action. event.preventDefault() cancels the default action but keeps bubbling. event.stopImmediatePropagation() also blocks later listeners on the same element.

IntersectionObserver

typescript
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) loadImage(entry.target as HTMLImageElement);
  }
}, { rootMargin: "200px", threshold: 0 });

document.querySelectorAll("img[data-src]").forEach((img) => observer.observe(img));

Use for lazy-loading images, infinite scroll sentinels, triggering animations on view. Cheap — browser-managed, not a scroll listener.

Layout Thrashing

Reading a layout property (offsetHeight, getBoundingClientRect, getComputedStyle) after a write forces synchronous layout.

Bad (thrashes):

typescript
for (const el of elements) {
  el.style.height = el.offsetHeight + 10 + "px";   // read forces layout on every write-back
}

Good (batched):

typescript
const heights = elements.map((el) => el.offsetHeight);    // all reads first
elements.forEach((el, i) => { el.style.height = heights[i] + 10 + "px"; });  // then writes

Even better: use a library like fastdom or schedule reads and writes into rAF.

requestAnimationFrame vs setTimeout

  • rAF runs before paint, aligned to display refresh (usually 60 or 120 Hz). Best for animation.
  • setTimeout(fn, 16) drifts; does not sync with the vblank; can double-fire in the same frame or miss.

Web Vitals

MetricWhatGoodHow to improve
LCP — Largest Contentful PaintTime until the largest above-the-fold element paints< 2.5 sOptimise hero image, preload, inline critical CSS
INP — Interaction to Next PaintWorst input delay across session< 200 msBreak up long tasks, offload to workers, virtualise lists
CLS — Cumulative Layout ShiftSum of layout shifts weighted by area< 0.1Size images, reserve ad space, no injecting content above existing

Also relevant: FCP (First Contentful Paint), TTFB (Time to First Byte), TBT (Total Blocking Time, lab-only).


Section 10: React Context and State

Context Re-Render Implications

Every consumer of a Context re-renders when the provided value reference changes. If you wrap half the app in a context whose value updates every second, half the app re-renders every second.

Anti-pattern:

typescript
<UserContext.Provider value={{ user, setUser, theme, setTheme, locale }}>

Changing any field forces re-renders for consumers that only care about user.

Fix: split contexts by update frequency. Or select specific fields in consumers via a selector pattern (requires a store like Zustand / Redux, not plain Context).

When Context is the Wrong Answer

  • High-frequency updates (cursor position, animation state).
  • Large value objects consumed by many components.
  • Fields that should trigger re-renders only for a subset of consumers.

Reach for a proper state store with selector subscriptions in those cases.

Redux vs Zustand vs Jotai vs Context

LibraryModelProsCons
Redux (Toolkit)Single store, reducers, immutable updatesPredictable, DevTools, middleware ecosystemVerbose; overkill for small apps
ZustandSingle store, hooks, mutable with immerTiny API, selector subscriptions for free, no providerLess opinionated; larger apps can sprawl
JotaiAtom-based, bottom-upGranular re-renders by default; async atomsMental model flip from Redux; debugging harder
ContextPlain React, built-inNo dependency; simpleNo selector subscriptions; re-renders all consumers

For a typical Salesforce-scale SaaS dashboard I lean Zustand with selector hooks plus React Query for server state. Redux where the team is already deep in it.

Custom Selector Pattern (Zustand-like)

typescript
type Store = { user: User | null; theme: "light" | "dark"; setTheme: (t: "light" | "dark") => void };

const useStore = create<Store>((set) => ({
  user: null,
  theme: "light",
  setTheme: (t) => set({ theme: t }),
}));

// Component only re-renders when theme changes
const theme = useStore((s) => s.theme);

With Context you'd need useMemo on the provider value plus splitting into multiple contexts to simulate this. Worth knowing both.

Server State — React Query / SWR

  • Don't put server data in global state. Use React Query: cache, dedupe, background refresh, stale-while-revalidate, request-on-focus.
  • Key by tenant + user + params; invalidate on mutation; use placeholderData for optimistic UX.
  • Pair with a small client-state store for UI ephemera.

Final Sprint Review

Before the Salesforce round, rehearse aloud:

  1. Trace console.log("A"); setTimeout(() => console.log("B")); Promise.resolve().then(() => console.log("C")); in under 30 seconds, naming queues.
  2. Write curry(add, 3) and explain arity.
  3. Write debounce with trailing edge.
  4. Explain which CSS property to animate for "move 200px right smoothly" and why left is wrong.
  5. Name three layout-triggering properties, three paint-triggering, and the two composite-only.
  6. Explain useMemo vs React.memo — when each is worth it, when each is pointless.
  7. Trace a React useEffect cleanup under Strict Mode double-invoke.
  8. Explain Context re-render cost and when to reach for Zustand / Redux.

If you can do those eight in ~15 minutes without notes, you're ready.

Frontend interview preparation reference.