
Back in 2024, I studied for a frontend platform team opening at a fintech company and kept the notes. It wasn't a job building user-facing services. It was a job building the dev environment and frameworks that those service developers use. Opening the notes two years later, the hiring round was obviously long over, and a lot of the tech listed had changed too.
But the six lines of take-home test scope in that posting still make a great checklist today. Git, module systems, package.json dependencies, bundlers, CI/CD, error detection. Those six cover almost everything about how a frontend dev environment works behind the scenes. So I kept the six lines as the skeleton and refilled the content for 2026.
- The components of Git and how commonly used commands work
- Using CommonJS and ECMAScript Modules in Node.js and their interoperability
- How dependencies, devDependencies, and peerDependencies behave in package.json
- How transpilers and bundlers work and how to use them
- Setting up CI/CD in a frontend dev environment
- Error detection and debugging for running frontend services and SSR servers safely
General frontend interview concepts are covered separately in Concepts Worth Reviewing Before Applying to Frontend Roles. This post sits one layer below that, on the dev environment side.
1. Git: Stop Downloading the Whole Repo#
Git is a store of objects: file contents (blobs), directory structures (trees), and snapshots (commits). You don't need to know this when the repo is small, but put hundreds of services into one monorepo and it's a different story. In a talk I watched, a team's repo grew to 40GB and a plain clone became flat-out impossible.
The first thing that comes to mind is a shallow clone (--depth). It's fast because it cuts off history, but that makes tracking changes hard, so it doesn't fit when you want to deploy only the services that changed. That's where partial clone, available since Git 2.22, comes in. You get the full history but fetch file contents (blobs) only when needed.
# Fetch all commits and trees, but pull file contents (blobs) on demand
git clone --filter=blob:none --sparse <repo>
# Pick the directories to materialize; only their blobs get downloaded
git sparse-checkout set apps/web packages/uiAs of 2026, partial clone combined with sparse-checkout is the standard setup for large monorepos. Sparse-checkout controls which paths land in your working tree, and cone mode, which only takes directory patterns, has been the default for set since Git 2.37. The two do different jobs. Partial clone defers downloads, and sparse-checkout shrinks what goes on disk.
CI runners go one step further. If checking out the repo on every run causes timeouts, you can bake a container image with the repo pre-cloned every day and fetch only the diff on top of it. In the case I saw, that cut a 36-minute checkout down to 22 seconds. If you use GitHub Actions, the filter option on actions/checkout gives you partial clone directly.
If you want a separate directory per branch, learn
git worktreetoo. It lets you check out multiple branches of one repo at the same time, which is especially handy when several agents work on the same repo concurrently.
2. CommonJS and ESM: require(esm) Works Now#
This was the only empty item in my 2024 notes. It's also the item that changed the most since then.
CommonJS (CJS) is Node.js's original module system, using require() and module.exports. It reads and executes files synchronously. ESM (ECMAScript Modules) is the standard module system using import and export, and since its dependency graph can be analyzed statically, it enables optimizations like tree shaking. Node.js decides which one a file is by the "type": "module" setting in package.json or by the .mjs and .cjs extensions.
The problem was mixing them. Importing CJS from ESM has worked for a long time, but calling require() on an ESM file didn't. So libraries commonly built two copies, CJS and ESM, and split them with the exports field, the so-called dual package approach. Two builds, two sets of types, twice the bugs.
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}This changed with Node 22.12 (December 2024). require() can now load ESM files without a flag, and the change was backported to Node 20.19. You can ship a single ESM build and CJS consumers can still require() it. The one limitation is modules that use top-level await. A synchronous require() can't wait for them, so you get an ERR_REQUIRE_ASYNC_MODULE error.
Two things to act on. Default new libraries to a single ESM build, and keep a dual package only when you must support an older Node version. And tidy up the condition order in your exports field (types, import, require, default) so different bundlers don't pick different files.
3. package.json: Three Dependency Fields and Package Managers#
Let's start with the difference between the three fields.
| Field | When it's installed | Use it for |
|---|---|---|
dependencies |
Always | Packages needed at runtime |
devDependencies |
Only in development. Skipped on production installs and on the consumer's side of a library | Build tools, tests, linters |
peerDependencies |
Not installed directly. Shares whatever the consumer installed | Packages that must exist only once, like React |
peerDependencies is the confusing one. The key word is singleton. If a UI library puts React in dependencies, the app's React and the library's React can both get installed, and Hooks break. So peer is a way of declaring "I'll use the React you installed." Since npm 7, peers are installed automatically, but a version mismatch should be treated as a potential runtime error, not just a warning.
What a package manager does with these fields comes in three stages. Resolution pins a range like "react": "^18.2.0" to an exact version, then pins the dependencies of that dependency too (the result is the lock file). Fetch downloads the pinned versions from the registry. Link makes the downloaded files usable from your source code. The Link stage is where package managers differ.
| Approach | How it works | Where it stands in 2026 |
|---|---|---|
| npm | Hoists dependencies into a flat node_modules |
Still the default, but it has the phantom dependency problem where you can use packages you never declared |
| pnpm | Downloads once into a global store and links with hard links and symlinks. Only declared packages are visible | Has become the de facto default for monorepos. Since 9.5, catalog: lets you manage versions across a workspace in one place |
| Yarn PnP | No node_modules. A dependency map lives in a JS file and require consults it |
Continues as Yarn 4 (Yarn Modern). Strict, but with a tooling compatibility cost |
| Bun | An installer integrated with its own runtime | Fast installs and growing adoption, but often still mixed with Node-based CI |
Phantom dependencies are a concept worth knowing. When npm hoists sub-dependencies to the top-level node_modules to reduce duplicates, packages you never listed in package.json become importable. It works today and breaks the day the dependency tree shifts. pnpm and PnP are strict precisely to prevent this.
One more thing: Corepack, which kept the whole team on the same package manager version, is no longer shipped with Node starting in Node 25. Keep pinning the version with the packageManager field in package.json, but follow the standalone install instructions from pnpm or Yarn.
4. Transpilers and Bundlers: The Chain Moved to Rust#
Browsers load files one at a time, but we develop across hundreds of files. The bundler bridges that gap in three stages.
- Resolution — Turns an ambiguous path like
import './utils'into an exact file path. Interpreting theexportsfield and conditional exports happens here too. - Load (Transform) — Converts TypeScript, JSX, and modern syntax into JS the browser understands. This is the transpiler's job.
- Optimization — Removes unused code (tree shaking, with exclusions marked via the
sideEffectsfield), shortens variable names, and compresses patterns (minification).
This structure is the same in 2024 and today. What changed is who does it. The work Babel and Terser used to do has moved to tools written in Rust or Go.
- Vite 8 (March 2026) dropped the esbuild plus Rollup combination and uses the Rust bundler Rolldown by default. Rolldown hit 1.0 in May 2026, and transpiling is handled by Oxc from the same team. For complex projects, the recommendation is to switch to the
rolldown-vitepackage on Vite 7 first, then upgrade to 8. - Next.js 16 makes Turbopack the default for both the dev server and production builds. If you have a custom webpack config, the
--webpackflag takes you back. - If you want to keep your webpack config and just get faster, Rspack offers a webpack-compatible API.
React Native is a different story. The default bundler Metro still has limited tree shaking. Expo supports it for production builds only behind experimental flags like EXPO_UNSTABLE_TREE_SHAKING, and otherwise the Rspack-based Re.Pack is used as an alternative. In a 2024 talk, I heard about a team that switched to esbuild themselves because of Metro's long builds and --reset-cache problems, and those problems still aren't fully solved.
Tool names keep changing, but the question stays the same. Which of the three stages is our build spending its time in? If you can answer that, you can decide whether to switch when the next tool comes along.
5. CI/CD: Build Only What Changed, Share the Cache#
In a monorepo, the number of builds grows as (changed services) × (environments). Build development, staging, and live separately and one service fix means three builds. In the case I saw, the team rewrote the pipeline to run every build in parallel on independent runners and finished in under 6 minutes in the ideal case. The principle hasn't changed. Splitting pipelines into isolated compute comes first.
In 2026, two more things come standard.
- Build only what's affected — Turborepo uses
turbo build --affectedand Nx usesnx affectedto run only the packages that changed versus main, plus their dependents. Lerna, once synonymous with monorepo tooling, is now maintained by the Nx team. - Remote caching — Same inputs, same outputs, so a build done on another runner gets reused as is. Turborepo uses Vercel's remote cache or self-hosting, and Nx uses Nx Cloud. On a cache hit the build is skipped entirely, which you really feel.
GitHub Actions has become the de facto standard CI platform. Combine the partial clone option on actions/checkout mentioned earlier, pre-baked runner images, and Docker layer caching (shared layers first, frequently changing JS last) and even a large repo becomes manageable.
Watch your deployment artifact size too. If you use something like Next.js standalone output that traces only the files actually needed at runtime, the case I saw turned a 40GB monorepo into a 200MB deployable. In environments where the node_modules layout is different, like pnpm or PnP, that tracing may not work out of the box, so check ahead of time.
6. Error Detection and Performance: Know Before You Deploy#
The last item is "running things safely." I'll split it into two axes: performance and errors.
Performance Through Web Vitals#
Google's three Core Web Vitals are the benchmark.
- LCP (Largest Contentful Paint) — Time until the largest piece of content is painted. Treated as the moment loading is done.
- INP (Interaction to Next Paint) — Delay from a user input until the next frame is painted. It replaced FID on March 12, 2024. Unlike FID, which only looked at the first input, INP looks at every interaction during the visit, and 200ms or less counts as good.
- CLS (Cumulative Layout Shift) — Accumulated layout movement.
My 2024 notes listed FID. If your measurement code still has FID in it, switch it to INP.
The big picture for cutting LCP is still SSR. Client rendering receives the HTML, downloads the JS bundle, executes it, and only then calls the API before content appears. With SSR, the server identifies the user from request headers, calls a nearby API server directly, and returns HTML with the content in a single request. In the case I saw, that brought average LCP down to 0.9 seconds. Today, React Server Components and streaming are baked into React 19 and the major frameworks, so deciding which components finish on the server and which ship to the client has become the center of the design.
The cost is that SSR adds build and deploy time, monitoring, and server expense. The CI/CD optimizations in section 5 didn't come out of nowhere.
Errors Within One Minute#
Many frontend teams have no system beyond a SaaS like Sentry for diagnosing service health and catching errors right away. The team I learned from had a clear goal: any new error is detected within one minute. Reaching that takes a combination, not one tool.
- Error collector plus source maps — Errors thrown from a minified bundle need to map back to the original code location. Put source map uploads into your deployment pipeline.
- Distributed tracing — OpenTelemetry-family tools that tie frontend requests and backend logs together under one trace ID have become the standard. You need it to see "which API was slow and pushed back LCP" on one screen.
- Canary deployments — Roll out to 1 server in 100 first, watch the error rate, then widen. It's less about catching errors fast and more about keeping errors from spreading.
- Pre-deploy performance comparison — You need to know something got slower before users tell you. Add a step that compares bundle size and Web Vitals against the previous build on every PR.
Testing improves with just two rules. Write a test when you fix a bug, and measure performance when you optimize. Playwright has become the standard for web E2E, and where real user behavior differs from test behavior (like the click event that fires alongside typing on mobile), it's better to close that gap at the library level than in the test code.
Wrapping Up: Six Questions to Audit With#
The posting from two years ago is closed, but its six lines turn into questions you can ask of the dev environment you use right now.
- How does our repo get cloned, and how many seconds does CI checkout take?
- Are the packages we publish a single ESM build, or still a dual package?
- Are the packages in
peerDependenciesreally the ones that must be singletons? Any phantom dependencies? - Which of Resolution, Load, and Optimization is our build spending time in?
- When one service changes, how many builds run, and how many of them are skipped by cache?
- When a new error happens, how many minutes until we know, and are we measuring INP?
Answer them one by one and the next thing to fix shows itself. The tool names will have changed again two years from now, but the questions will be the same.
Until you make peace with who you are, you will never be content with what you have.
— Doris Mortman


