Fast Feedback Loops: Watch Mode, Hot Reload, Tests
The inner loop is the cycle you repeat all day: change some code, save, find out whether it worked. You might run a full build and test pass a few times a day, but you go around the inner loop constantly, so a few seconds saved there add up faster than anywhere else. The goal is simple to state: the moment you save, the right signal should already be on its way.
Time the loop, not the build
The number that matters here is not build time. It is the time from pressing save to seeing a result you trust: a page that updated, a test that passed, a type error with a line number.
Pick your three most common kinds of edit, for example a styling change, a logic change in a function that has tests, and a change to a shared type. For each one, time how long it takes to get an answer, and note what you actually waited on. Was it a process restarting? A browser reload that threw away the state you had built up? A test runner starting the whole suite? The answer tells you which of the sections below to start with.
Let a watcher do the rerunning
If you are retyping a command after every save, stop. Most toolchains can keep a process alive, hold what they already know in memory, and redo only the work a change requires.
For a Node.js server, the runtime has this built in. node --watch server.js starts the process in watch mode and restarts it whenever the entry point or any module it requires or imports changes. The Node.js CLI documentation marks watch mode as stable since versions 22.0.0 and 20.13.0. If you would rather name the folders yourself, --watch-path restarts on changes under the paths you list and turns off the automatic tracking of imported modules. Check your platform first: the docs say --watch-path is only supported on macOS and Windows, and it throws an error on other systems, so on Linux stick with plain --watch.
node --watch server.js
# macOS and Windows only
node --watch-path=./src --watch-path=./config server.js
A restart is still a restart, though. It throws away in-memory state, open connections and warm caches. For a small API server that costs almost nothing. For a front end, where the state you care about lives in the browser, you want something finer.
Use hot module replacement for front-end work
Reloading the whole page after every edit resets everything: the form you half filled, the modal you opened, the route you clicked through to reach the screen you are working on. Hot module replacement (HMR) swaps only the module you changed into the running page.
According to the Vite documentation, Vite provides an HMR API over native ES modules, and frameworks with HMR support use it to deliver instant, precise updates without reloading the page or losing application state. Vite ships first-party integrations for Vue single-file components and React Fast Refresh, and its starter templates come with them configured.
A few habits keep HMR working in your favor:
- Keep side effects out of module top levels. A module that starts timers, opens sockets or registers global listeners when it loads can run that setup twice when it is swapped. Put that work inside functions or framework lifecycle hooks that know how to clean up.
- Notice when you get a full reload instead of a hot update. If one particular file always reloads the whole page, find out why rather than accepting it. The dev server's terminal output and the browser console are the first places to look.
- Keep the page you are working on reachable in one step. Hot updates preserve state, but a full reload still sends you back to the start. A direct URL or a dev-only route to the screen under construction saves a lot of clicking.
Run only the tests your change touches
Running the entire suite after every save is the slowest possible version of the loop. Modern test runners can use the import graph to pick the tests that depend on what you changed.
Jest. The Jest CLI offers several ways to narrow a run:
jest --watchwatches files and reruns only the tests related to changed files.--watchAllreruns everything instead.jest --onlyChanged(or-o) picks tests based on the files changed in the current repository. It needs git or Mercurial and a static dependency graph, which means no dynamicrequirecalls.jest --findRelatedTests src/cart.ts src/price.tsruns the tests that cover the files you name, which suits a pre-commit hook.jest --onlyFailures(or-f) reruns only the tests that failed last time.
Vitest. The Vitest CLI starts in watch mode when you run vitest in an interactive terminal and falls back to a single run in CI or when input is not a terminal. vitest related runs only the tests that cover a list of source files. It follows static imports but not dynamic ones such as import(filepath). Vitest's docs note that tools like lint-staged also need --run so the command exits instead of watching. There is also a --changed option for tests affected by changed files.
npx vitest related --run src/cart.ts src/price.ts
pytest. Python projects get similar shortcuts from pytest's cache plugin. --lf (--last-failed) reruns only the failures, --ff (--failed-first) runs the failures first and then everything else, and --sw (--stepwise) stops at the first failure and resumes from that test on the next run, so you can fix failures one at a time.
One caution applies to all of these. Selection by import graph only sees what the graph can see. A test that reads a fixture file, depends on configuration, or loads a module dynamically can be skipped when it should have run. Treat affected-only runs as the fast path while you work, and run the whole suite once before you push.
Get type errors in the editor, not later
The fastest type feedback is the red underline that appears while you are still typing. Your editor gets it from a language server, a background process that keeps the project loaded and answers questions about it as you edit. If that underline is slow to appear, the fix is usually the same as for a slow compile: a smaller project for the tool to load.
Some errors only show up across files, and it helps to have a second, always-on check for those. Vite's documentation recommends that if you need more than editor hints during development, you run tsc --noEmit --watch in a separate process, or use vite-plugin-checker to report type errors directly in the browser. The --noEmit flag matters: it makes the compiler check types without writing any output files, so the watcher never drops compiled JavaScript into your project for the dev server to trip over.
Other languages have the same split between checking and building. The Rust book explains that cargo check is often much faster than cargo build because it skips producing an executable, which makes it the right command to run continuously while you write code.
Put the loop together
A tight setup for a typical TypeScript front end is three long-running processes, each answering a different question on every save:
# terminal 1: the app, with hot module replacement
npx vite
# terminal 2: type errors across the whole project, no output files
npx tsc --noEmit --watch
# terminal 3: tests related to what you changed
npx vitest
Arrange them so you can see all three without switching windows. On each save you get a hot update in the browser, a type verdict, and a test verdict for the affected files, usually before you have looked away from the code. Then add a pre-commit hook that runs vitest related --run on the staged files, so nothing reaches a commit without the tests that cover it.
If you only change one thing after reading this, time your most common edit and fix the single step you spend the most time waiting on. The inner loop usually has one dominant delay, and removing it is worth more than tuning everything else.