Frontend / Checklist
JavaScript rules that keep the main thread and your sanity
Twenty-six rules for the JavaScript you ship to browsers: what must never run, how code is organized and typed, what keeps the main thread free for input, and how data crossing a boundary gets checked before the app trusts it. The priorities separate the one rule that opens a script-injection hole from the many that keep a codebase pleasant to work in a year from now.
26 rules in 7 groups. Adapted from the Front-End Checklist (David Dias, MIT), rewritten and re-verified September 2026.
What this checklist decides
JavaScript is where a frontend fails loudly and silently at the same time. An eval or an unchecked innerHTML is a security hole; a long task on the main thread is a bad Interaction to Next Paint score on every phone; an any at an API boundary is a runtime crash three months later, the day the backend renames a field. None of the three shows up in a screenshot review.
Security comes first because its one Critical rule is a ship blocker on its own. Modules, strict types and schema validation are cheap to adopt on the first day and painful to retrofit, so their High priority is about when, not whether. The performance rules matter once real users on real devices arrive, which is also when they become hard to diagnose.
- Critical blocks the ship
- High fix before launch
- Medium fix this quarter
- Low worth it when cheap
Security
Two rules the frontend owns outright; the headers and cookie flags that back them up are in the security checklist.
| Rule | Priority | What to do |
|---|---|---|
| Never execute strings as code | Critical | No eval(), no new Function(), no string arguments to setTimeout, and no innerHTML, outerHTML, insertAdjacentHTML or document.write with anything a user or an API supplied. textContent for text, DOMPurify when HTML must be injected, and a CSP without 'unsafe-eval' plus require-trusted-types-for 'script' to turn the rule into enforcement. |
| Check the origin on every cross-origin exchange | High | postMessage(data, 'https://exact.origin'), never '*', and if (event.origin !== EXPECTED) return; as the first line of the listener. CORS on the server names specific origins and never pairs * with credentials; fetch sends credentials: 'include' only where a cookie is genuinely needed; external target="_blank" links carry rel="noopener". |
Gotcha: the framework does not save you. React's dangerouslySetInnerHTML, Vue's v-html, Svelte's {@html} and Angular's bypassSecurityTrustHtml are all innerHTML with a scarier name. Sanitize before every one of them, or render the data as text.
Modules and variables
How code is split into files and how bindings are declared. Both are settled questions; the rules exist because old habits are still in the templates people copy.
| Rule | Priority | What to do |
|---|---|---|
| Ship ES modules | High | import and export everywhere, <script type="module"> in the page, "type": "module" in package.json. Modules are strict mode by default, deferred by default, and the only format a bundler can tree-shake and split. CommonJS stays on the server side of the build, if anywhere. |
| const first, let when it changes, var never | High | var is function-scoped and hoisted, which is how loop closures capture the wrong value and how typos become globals. ESLint no-var and prefer-const as errors make the rule free. |
| Mark type-only imports as type-only | Low | import type { User } from './types', or inline import { type User, loadUser }. The import is erased at compile time, so no runtime side effect and no surprise circular dependency; it is required under verbatimModuleSyntax and by single-file transpilers (esbuild, SWC) that cannot look across files to tell a type from a value. |
Gotcha: a <script type="module"> is always deferred and always fetched with CORS, so document.currentScript is null and code that expected to run before the DOM finished parsing now runs after. Both are improvements - until a copied analytics snippet depends on the old behavior.
Best practices
Six rules about the surrounding discipline: failures, inline code, the compiler, the console, the linter and the strings.
| Rule | Priority | What to do |
|---|---|---|
| Handle every failure path | High | try/catch around every await that can fail, .catch() on any promise you do not await, error and unhandledrejection listeners on window that report to Sentry or equivalent, and an error boundary around each route in React (react-error-boundary). Never swallow: log it, show it, or rethrow it with cause. |
| No inline JavaScript | High | No onclick="", no javascript: URLs, no logic in inline <script> blocks. The reason is Content Security Policy: a hashed script-src has to carry a hash for every inline block, and inline event handlers only work at all with 'unsafe-hashes'. addEventListener in a module file needs neither. |
| Turn on TypeScript strict mode | High | "strict": true in tsconfig.json switches on strictNullChecks, noImplicitAny, strictFunctionTypes and the rest of the family; add noUncheckedIndexedAccess, noImplicitOverride, exactOptionalPropertyTypes and verbatimModuleSyntax on top. Day one, not later: retrofitting strictness onto a large codebase is weeks of non-null assertions. |
| Strip console output from the production bundle | Medium | terser with compress: { drop_console: true }, or pure_funcs: ['console.log'] to keep console.error; esbuild with drop: ['console', 'debugger'], which in Vite is the esbuild.drop config key. ESLint no-console as a warning catches them before the build has to. |
| Lint on every commit | Medium | ESLint 9 flat config (eslint.config.js) with typescript-eslint's recommendedTypeChecked preset, plus a formatter - Prettier, or Biome for linting and formatting in one fast tool. Run through lint-staged on pre-commit and again in CI so the hook cannot be skipped. |
| Write strings a translator can translate | Medium | Whole sentences with named placeholders - {name} added {count, plural, one {# item} other {# items}} - through an ICU MessageFormat library (FormatJS, i18next, Lingui, Paraglide). Never assemble a sentence from fragments and never wrap only part of one in a link: word order changes per language and the fragments cannot be reordered. |
The compiler options worth turning on before the first file is written:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
}
} Gotcha: drop_console removes console.error and console.warn too, which are usually the calls you wanted to keep in production logs. Use pure_funcs to name exactly what goes.
Performance
Three High rules because each one shows up as a number a user feels: bundle size on the first visit, jank on every interaction, and memory that climbs until the tab dies.
| Rule | Priority | What to do |
|---|---|---|
| Split the bundle at routes and heavy features | High | Dynamic import() at each route and around editors, charts, maps and anything below the fold; React.lazy or defineAsyncComponent at the component level. Vite splits every import() into its own chunk automatically and build.rollupOptions.output.manualChunks groups vendors. Measure with rollup-plugin-visualizer before and after. |
| Batch DOM reads and writes | High | Reading offsetHeight or getBoundingClientRect() right after a style write forces a synchronous layout; in a loop that is layout thrashing. Read everything first, then write everything, inside requestAnimationFrame; build lists in a DocumentFragment; watch sizes with ResizeObserver instead of polling. |
| Clean up everything you subscribe to | High | Pass an AbortController signal to addEventListener and abort it on teardown, clear intervals, disconnect() every observer, return a cleanup function from every effect, and keep DOM references in a WeakMap. Prove it: take a heap snapshot, navigate away and back three times, take another, and compare. |
Gotcha: the leak that survives code review is a listener on window or document added by a component that later unmounts. Nothing removes it, the closure keeps the whole component tree alive, and after the second mount it fires twice.
Optimization
Two rules, one about how often a handler runs and one about how many bytes it takes to get there.
| Rule | Priority | What to do |
|---|---|---|
| Debounce input, throttle movement | High | Debounce search-as-you-type and window resize; throttle scroll, pointermove and mousemove; pass { passive: true } on scroll and touch listeners so the browser never waits on them. Better still, replace the listener: IntersectionObserver for visibility, ResizeObserver for size, scrollend for "scrolling stopped". |
| Minify the bundle | High | esbuild is Vite's default minifier and fast; terser squeezes out a little more when the last kilobytes matter; the CDN applies Brotli on top. Upload source maps to the error tracker rather than referencing them from the bundle, unless you have decided you want them public. |
A debounce is a dozen lines; reach for lodash-es/debounce only if it is already in the bundle:
function debounce(fn, wait = 250) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
const search = debounce((query) => fetchResults(query));
input.addEventListener('input', (event) => search(event.target.value));
// Scroll: let the browser skip waiting on the handler at all
window.addEventListener('scroll', onScroll, { passive: true }); Gotcha: scrollend fires once when scrolling settles, which is what most throttled scroll handlers were trying to approximate. It arrived in browsers at different times, so feature-detect with 'onscrollend' in window and fall back to a debounced scroll where it is missing.
Patterns and quality
Seven rules about trusting data and the type system. The first one is High because it is the difference between a caught error and a blank screen.
| Rule | Priority | What to do |
|---|---|---|
| Validate data at every boundary with a schema | High | Zod, Valibot or ArkType on API responses, JSON.parse output, URL parameters, localStorage, form data and import.meta.env. Derive the TypeScript type from the schema with z.infer so the check and the type cannot drift apart, and use safeParse where a failure is an expected outcome rather than a bug. |
| Treat data as immutable | Medium | Spread and structuredClone instead of mutating; toSorted, toReversed, toSpliced and with return copies where sort and reverse mutate in place; readonly and as const in TypeScript; Immer for deep updates. Frameworks detect change by reference, so an in-place mutation is an update they never see. |
| Use the built-ins before a utility library | Medium | at(), flatMap, findLast, Object.groupBy, Object.hasOwn, structuredClone, Promise.allSettled, Promise.withResolvers, the Set algebra methods. Set the build target to baseline-widely-available (Vite's default) and stop transpiling and polyfilling for browsers nobody runs. |
| Parse JSON defensively | Medium | JSON.parse throws a SyntaxError on malformed input, so wrap it and hand the result to a schema; await res.json() rejects the same way and says nothing about a 500, so check res.ok first. JSON.parse(x) as T is a lie the compiler believes. |
| Ban any | Medium | unknown at the boundary, narrowed with a type guard or a schema; generics for functions that work on many types; @typescript-eslint/no-explicit-any as an error. any switches off checking for everything it touches and spreads through return values into code that never asked for it. |
| Enable noUncheckedIndexedAccess | Medium | With it on, list[i] and record[key] are typed T | undefined, so the compiler makes you handle the miss before it becomes "cannot read properties of undefined" in production. Pair it with .at() and optional chaining and the extra checks read naturally. |
| Compare and convert explicitly | Medium | === and !== always (ESLint eqeqeq, with null: "ignore" if the team likes the == null idiom); Number(x), String(x) and Boolean(x) instead of +x, '' + x and !!x; parseInt(x, 10) with the radix every time. |
One schema, one inferred type, one place where the outside world is allowed in:
import { z } from 'zod';
const User = z.object({
id: z.uuid(),
email: z.email(),
role: z.enum(['admin', 'member']).default('member'),
});
type User = z.infer<typeof User>;
export async function loadUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`users/${id} returned ${res.status}`);
return User.parse(await res.json()); // throws ZodError on a shape mismatch
} Gotcha: a schema at the boundary does not protect the inside of the app if the type next to it is written by hand. Infer the type from the schema, or the day the backend renames a field the runtime check rejects it while the type still insists it is there.
Async, events and storage
Three rules about sharing the main thread, listening efficiently, and what belongs in the browser's storage.
| Rule | Priority | What to do |
|---|---|---|
| Yield inside long tasks | Medium | Any task over 50 ms blocks input and drags Interaction to Next Paint. Split loops into chunks and await scheduler.yield() between them - it resumes ahead of other queued work, unlike setTimeout(0). Feature-detect and fall back where it is missing; scheduler.postTask adds priorities when the work can wait. |
| Delegate events for dynamic content | Medium | One listener on the container and event.target.closest('[data-action]') inside it instead of a listener per row; rows added later just work and nothing has to be cleaned up per row. focus and blur do not bubble - delegate focusin and focusout. Frameworks already delegate at the root; this is for the vanilla parts. |
| Use Web Storage for preferences only | Medium | localStorage is synchronous, string-only, shared by every tab on the origin and readable by any script that runs there - never a token, never personal data. Wrap every read and write in try/catch (quota, private mode, storage disabled), version the key, run the parsed value through a schema, and move anything structured or large to IndexedDB through idb. |
A loop that hands the thread back before it becomes a long task:
// Fallback is a macrotask; a MessageChannel port.postMessage() yields sooner than setTimeout if it matters
const yieldToMain = () =>
'scheduler' in globalThis && 'yield' in scheduler
? scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
async function processAll(items) {
let deadline = performance.now() + 40;
for (const item of items) {
process(item);
if (performance.now() > deadline) {
await yieldToMain();
deadline = performance.now() + 40;
}
}
} Gotcha: yielding only helps if the work between yields is short. A loop that yields after a chunk that itself takes hundreds of milliseconds is still a long task, just a slightly shorter one. Check the chunk length against the 50 ms budget in the Performance panel, not in your head.
Keep going
The Critical and High rows here are folded into the launch checklist. The eval rule and the CSP it leans on are one half of the picture; the headers, cookie flags and secret handling are the other half, in the security and privacy checklist. Bundle size, long tasks and memory show up as the metrics in the performance checklist.
Schema validation is the boundary rule; where the validated data then lives - server state, client state, URL state, forms - is the whole subject of state and data. For the language itself, the TypeScript cheatsheet covers the compiler options and narrowing patterns this page names, and the JavaScript arrays cheatsheet lists which methods copy and which mutate.
Minification, splitting and console stripping are all build configuration; the build tools guide compares Vite, esbuild, Rollup and the rest and says which one to pick.