Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

27 chapters
⌕
Beginner9›
B1Setup & mental modelB2Types & valuesB3Operators & flowB4Functions (first half)B5Objects & arrays (first half)B6DOM & eventsB7Basic asyncB8Errors & tools★Cheat page
Intermediate10›
I1Scope & functions, properlyI2Objects deeplyI3Prototypes & OOPI4Async, properlyI5Modules & toolingI6Regex, dates & APIsI7Error handlingI8Real-time connectionsI9Offline & storage★Cheat page
Advanced10›
A1Engine & memoryA2Advanced asyncA3MetaprogrammingA4Types & dataA5Patterns & architectureA6PerformanceA7SecurityA8EcosystemA9Testing★Cheat page
/ search[ ] chaptert top

JavaScript levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
A6

Performance

Making a page feel fast is a different skill than making code run fast.

The critical rendering path

What actually has to happen before a browser can paint a single pixel: download the HTML, parse it into a DOM, download and parse CSS into a CSSOM, combine the two into a render tree (only the nodes that will actually be visible), compute every element's exact size and position (layout, also called reflow), then finally paint pixels for each one. A <script> with no defer/async blocks this whole pipeline at the HTML-parsing step — the exact mechanism behind the script-vs-module blocking behavior from the very first chapter.

Reflow (layout) Repaint
Triggered byanything that changes size or position — width, font-size, adding/removing an elementanything that changes appearance only — color, background, visibility
Costexpensive — can cascade to the whole subtree, sometimes the whole pagecheaper — no geometry to recompute
Cheapest of alltransform and opacity — these two can often skip layout AND paint entirely, handled straight on the compositor thread
⚠ Reading layout in a loop forces it early, repeatedly el.offsetHeight (or getBoundingClientRect()) forces the browser to run layout right now if anything is pending, instead of waiting for its natural time. Alternating writes and reads of layout properties in a loop — el.style.width = x; console.log(el.offsetHeight);, repeated — forces a full synchronous reflow on every iteration. This pattern has an actual name: layout thrashing. The fix is always the same shape: batch every read first, then batch every write.

Not blocking the main thread

requestAnimationFrame(fn) schedules fn to run right before the browser's next paint — the correct place for any animation logic, because it's synced to the actual screen refresh instead of guessing at a delay like setTimeout would. requestIdleCallback(fn) is the opposite priority: run fn only when the browser is otherwise idle, with time to spare before the next frame — for genuinely low-priority work (analytics batching, prefetching) that should never compete with anything the user is actually looking at.

function animate() {
  el.style.transform = "translateX(" + x + "px)";
  x += 2;
  if (x < 300) requestAnimationFrame(animate);   // re-schedule for the NEXT frame
}
requestAnimationFrame(animate);

requestIdleCallback(() => {
  sendAnalyticsBatch();   // only runs if the browser has spare time before the next frame
});

Debounce and throttle solve a different problem — how often a handler runs at all — and compose naturally with this: throttle a scroll handler down to a sane rate, then do the actual DOM write inside requestAnimationFrame so it lands at the right moment in the render pipeline.

The metrics that actually get measured

Metric Measures
LCP — Largest Contentful Painthow long until the biggest visible element (usually a hero image or heading) renders
INP — Interaction to Next Painthow long the page takes to visibly respond to a click, tap, or keypress — replaced the older FID metric for exactly this reason: FID only measured the delay before a handler started running, INP measures the whole thing including how long the handler itself takes
CLS — Cumulative Layout Shifthow much visible content jumps around unexpectedly — an image with no reserved width/height popping in and shoving everything below it down is the classic cause

A long task is any single chunk of main-thread JS running longer than 50ms without yielding — the main thread can't paint, or respond to input, until it's done, so a long task directly hurts both LCP and INP at once. Lighthouse is the tool that turns all of this into one number and a prioritized list of fixes; the metrics above are what it's actually measuring underneath that score.

Rendering less, later, or not yet

// Virtual list — render only the ~20 rows actually visible, not all 50,000
function VirtualList({ items, rowHeight, viewportHeight }) {
  const [scrollTop, setScrollTop] = useState(0);
  const start = Math.floor(scrollTop / rowHeight);
  const visibleCount = Math.ceil(viewportHeight / rowHeight);
  const visible = items.slice(start, start + visibleCount);
  // render "visible" only, with top/bottom spacers sized to fill the scroll area
}

A virtual list keeps DOM node count roughly constant regardless of data size — 50,000 rows and 50 rows cost the same, because only what's actually in the viewport (plus a small buffer) is ever mounted. loading="lazy" on an <img> is the built-in, no-JS version of the same idea for images below the fold. Prefetching is the opposite bet — load something before it's needed, on a strong signal it's about to be (hovering a link, an IntersectionObserver from two chapters back firing near the bottom of the page) — trading a little wasted bandwidth on guesses that don't pan out for a page that already has the next thing ready.

Tree shaking and bundle size

Already covered: tree shaking only works because ESM imports are static and analyzable — a bundler can see the entire dependency graph and delete anything provably unused. "Provably" is the load-bearing word: a module with side effects at its top level (code that runs just from being imported — registering something globally, patching a prototype) can't be safely deleted even if nothing imports a name from it, because deleting it would change behavior. package.json's "sideEffects": false field is a library author's explicit promise that none of their files do this, which is what lets a bundler tree-shake it aggressively instead of playing it safe.

A bundle analyzer (a treemap of what's actually inside the shipped JS, sized by byte) is how "why is this bundle 400kb" stops being a guess — it routinely surfaces one unexpectedly heavy dependency, or an entire library imported for one small utility function that could have been hand-written in ten lines instead.

Practice this layer

Opens in the editor — write it, run it, and check it against real tests.

A cache that can't grow forever2 tests · advancedProcess a big array in chunks3 tests · advanced
←previousPatterns & architecture↑ CovernextSecurity→