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
| Topic | Key fact |
|---|---|
| GPU-accelerated properties | transform, opacity — no Layout, no Paint |
| Layout-triggering properties | width, height, top, left, margin, padding, border, font-size |
| Paint-triggering properties | color, background, box-shadow, border-radius, visibility |
| Frame budget | 16.67 ms at 60 fps; 8.33 ms at 120 fps |
| Render pipeline | JS → Style → Layout → Paint → Composite |
| Microtask | Promise.then, queueMicrotask, MutationObserver |
| Macrotask | setTimeout, setInterval, postMessage, I/O, UI events |
requestAnimationFrame | Runs before next paint, not in either queue |
requestIdleCallback | Runs when browser is idle; low-priority |
| Event delegation | Attach one listener at ancestor; use event.target |
| Event phases | Capture (down) → Target → Bubble (up) |
| Debounce | Fire once after quiet period; use for search inputs |
| Throttle | Fire 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)
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
| Source | Queue |
|---|---|
setTimeout, setInterval | Macrotask |
setImmediate (Node) | Macrotask |
MessageChannel.postMessage | Macrotask |
requestAnimationFrame | Neither — separate rAF callback list |
| I/O, UI events | Macrotask |
Promise.then / catch / finally | Microtask |
queueMicrotask(fn) | Microtask |
MutationObserver | Microtask |
async function continuation | Microtask |
Why Promise.then Fires Before setTimeout(0)
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");Output: A D C B.
Trace:
- Sync frame runs
"A", schedulessetTimeout(macrotask), schedules.then(microtask), runs"D". - Call stack empties. Microtask queue drains —
"C"prints. - Next macrotask picked — the timer callback —
"B"prints.
queueMicrotask, requestAnimationFrame, requestIdleCallback
queueMicrotask(fn)— schedulefnon 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 withsetTimeoutfallback.
Trace Example 1 — rAF + microtask interleave
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
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
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
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
requestIdleCallbackor schedule viaMessageChannel.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.
function makeCounter() {
let count = 0;
return {
increment: () => ++count,
value: () => count,
};
}
const c = makeCounter();
c.increment();
c.increment();
console.log(c.value()); // 2count 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.
- Factories —
multiplier(n)returns a function multiplying byn. - Currying — next section.
- Event handler identity — capture
this-free references.
once(fn) — run a function at most once
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
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
function prefixer(prefix: string): (s: string) => string {
return (s) => `${prefix}${s}`;
}
const errorPrefix = prefixer("[error] ");
errorPrefix("oops"); // "[error] oops"Common Gotcha — var in loop
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
function addThreeNumbers(a: number): (b: number) => (c: number) => number {
return (b: number) => (c: number) => a + b + c;
}
addThreeNumbers(1)(2)(3); // 6Generic curry(fn, arity)
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); // 6A strict version supporting partial application from the left only:
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)
function partial<F extends AnyFn>(fn: F, ...preset: unknown[]): AnyFn {
return (...rest: unknown[]) => fn(...preset, ...rest);
}
const add10 = partial(add, 10);
add10(5, 6); // 21Difference: 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:
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:
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
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
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
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:
- Wrap
fnin a ref souseMemodoesn't have to re-create the throttled function every render. - 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
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
async function foo() {
const x = await fetchX();
return x + 1;
}Desugars to:
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
| Combinator | Resolves when | Rejects when |
|---|---|---|
Promise.all([p1, p2]) | all resolved → [r1, r2] | any rejected → first rejection |
Promise.allSettled([p1, p2]) | all settled → `[{status, value | reason}]` |
Promise.race([p1, p2]) | first settles (resolve or reject) | same |
Promise.any([p1, p2]) | first resolved | all rejected → AggregateError |
Polyfill — Promise.all
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
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
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
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
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
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
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
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
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
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)
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
| Tool | Memoises | Use when |
|---|---|---|
useMemo(() => x, deps) | Value | Expensive computation; referential stability of returned object |
useCallback(fn, deps) | Function reference | Passing to memoised child or to a dep array |
React.memo(Component) | Re-render decision | Pure 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:
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
- Missing deps — causes stale closures. Lint with
react-hooks/exhaustive-deps. - Cleanup — always return a cleanup for subscriptions, timers, aborts.
- Race conditions — fetch on deps change without aborting.
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]);- Double invoke in Strict Mode (dev) — React 18 intentionally mounts, unmounts, and remounts components in development. Effects must be idempotent under this.
- setState in effect loop — unguarded
setStateinside an effect that depends on that state creates an infinite loop.
Custom Hook Patterns
Data fetcher with abort + cleanup:
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:
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:
- JavaScript — event handlers, timers, style changes.
- Style — recompute matched CSS rules for affected elements.
- Layout (aka Reflow) — compute geometry: size, position, flow.
- Paint — rasterise pixels for each layer.
- 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-heighttop,left,right,bottommargin,padding,border-widthfont-size,line-height,font-weightdisplaychanges that affect flowpositionchangesfloat
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:
colorbackground,background-color,background-imageborder-radius,border-colorbox-shadow,text-shadowvisibilityoutline
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:
transform—translate,scale,rotate,skew,matrixopacityfilter(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
.box {
position: absolute;
left: 0;
transition: left 300ms ease;
}
.box.open { left: 240px; } /* triggers Layout every frame */Example — the right way
.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:
- 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.
- Rendering tab → Frame Rendering Stats — live FPS meter overlay.
- Performance.now() loop with
requestAnimationFrame:
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);- Web Vitals — INP (Interaction to Next Paint) — real-user metric. Good: under 200 ms.
Achieving 60 fps:
- Animate
transformandopacityonly. - Use
will-change: transformto 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: autoon offscreen content to skip layout for hidden sections. - Use
contain: layout paintto 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 towill-changein 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
| Position | Flow | Containing block | Effect |
|---|---|---|---|
static | In flow | Parent | Default; cheap |
relative | In flow | Parent | Still affects siblings; offsets are render-time only |
absolute | Out of flow | Nearest positioned ancestor | Doesn't affect siblings; still triggers layout of container |
fixed | Out of flow | Viewport | Often promoted to its own layer on scroll |
sticky | Hybrid | Scroll container | Browser 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.
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
- Capture — from window down to target. Pass
{ capture: true }to listen here. - Target — at the target element.
- 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
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):
for (const el of elements) {
el.style.height = el.offsetHeight + 10 + "px"; // read forces layout on every write-back
}Good (batched):
const heights = elements.map((el) => el.offsetHeight); // all reads first
elements.forEach((el, i) => { el.style.height = heights[i] + 10 + "px"; }); // then writesEven better: use a library like fastdom or schedule reads and writes into rAF.
requestAnimationFrame vs setTimeout
rAFruns 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
| Metric | What | Good | How to improve |
|---|---|---|---|
| LCP — Largest Contentful Paint | Time until the largest above-the-fold element paints | < 2.5 s | Optimise hero image, preload, inline critical CSS |
| INP — Interaction to Next Paint | Worst input delay across session | < 200 ms | Break up long tasks, offload to workers, virtualise lists |
| CLS — Cumulative Layout Shift | Sum of layout shifts weighted by area | < 0.1 | Size 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:
<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
| Library | Model | Pros | Cons |
|---|---|---|---|
| Redux (Toolkit) | Single store, reducers, immutable updates | Predictable, DevTools, middleware ecosystem | Verbose; overkill for small apps |
| Zustand | Single store, hooks, mutable with immer | Tiny API, selector subscriptions for free, no provider | Less opinionated; larger apps can sprawl |
| Jotai | Atom-based, bottom-up | Granular re-renders by default; async atoms | Mental model flip from Redux; debugging harder |
| Context | Plain React, built-in | No dependency; simple | No 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)
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
placeholderDatafor optimistic UX. - Pair with a small client-state store for UI ephemera.
Final Sprint Review
Before the Salesforce round, rehearse aloud:
- Trace
console.log("A"); setTimeout(() => console.log("B")); Promise.resolve().then(() => console.log("C"));in under 30 seconds, naming queues. - Write
curry(add, 3)and explain arity. - Write
debouncewith trailing edge. - Explain which CSS property to animate for "move 200px right smoothly" and why
leftis wrong. - Name three layout-triggering properties, three paint-triggering, and the two composite-only.
- Explain
useMemovsReact.memo— when each is worth it, when each is pointless. - Trace a React
useEffectcleanup under Strict Mode double-invoke. - 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.