By default, remix dev
drives like an automatic.
It keeps your app server up-to-date with the latest code changes by automatically restarting the app server whenever file changes are detected in your app code.
This is a simple approach that stays out of your way and we think will work well for most apps.
But if app server restarts are slowing you down, you can take the wheel and drive remix dev
like a manual:
remix dev --manual -c "node ./server.js"
That means learning how to use the clutch to shift gears. It also means you might stall while your getting your bearings. It takes a bit more time to learn and its more code for you to maintain.
With great power comes great responsibility.
We don't think its worth it unless you're feeling some pain with the default automatic mode. But if you are, Remix has got you covered.
remix dev
Before you start drag racing, it helps to understand how Remix works under the hood.
It's especially important to understand that remix dev
spins up not one, but two servers: the dev server and the app server.
The dev server is a glorified compiler running in watch mode. The browser never sends requests to the dev server. You probably shouldn't care what port it runs on.
Check out our video "Mental model for the new dev server 🧠" for more details.
When you switch on manual mode with --manual
, you take on some new responsibilities:
Re-importing code changes turns out to be tricky because JS imports are cached.
import fs from "node:fs";
const original = await import("./build/index.js");
fs.writeFileSync("./build/index.js", someCode);
const changed = await import("./build/index.js");
// ^^^^^^^ this will return the original module from the import cache without the code changes
You need some way to bust the import cache when you want to re-import modules with code changes.
Also importing modules is different between CommonJS (require
) and ESM (import
) which makes things even more complicated.
If you are using tsx
or ts-node
to run your server.ts
, those tools may be transpiling your ESM Typescript code to CJS Javascript code.
In this case, you'll need to use CJS cache busting in your server.ts
even though the rest of your server code uses import
s.
What matters here is how your server code is executed not how its written.
require
cache bustingCommonJS uses require
for imports, giving you direct access to the require
cache.
That lets you bust the cache for just the server code when rebuilds occur.
For example, here's how to bust the require
cache for the Remix server build:
const path = require("node:path");
/**
* @typedef {import('@remix-run/node').ServerBuild} ServerBuild
*/
const BUILD_PATH = path.resolve("./build/index.js");
/**
* Initial build
* @type {ServerBuild}
*/
const build = require(BUILD_PATH);
/**
* @type {() => ServerBuild}
*/
const reimportServer = () => {
// 1. manually remove the server build from the require cache
Object.keys(require.cache).forEach((key) => {
if (key.startsWith(BUILD_PATH)) {
delete require.cache[key];
}
});
// 2. re-import the server build
return require(BUILD_PATH);
};
The require
cache keys are absolute paths so make sure you resolve your server build path to an absolute path!
import
cache bustingUnlike CJS, ESM doesn't give you direct access to the import cache. To workaround this, you can use a timestamp query parameter to force ESM to treat the import as a new module.
import * as fs from "node:fs";
import * as path from "node:path";
/**
* @typedef {import('@remix-run/node').ServerBuild} ServerBuild
*/
const BUILD_PATH = "./build/index.js";
/**
* Initial build
* @type {ServerBuild}
*/
const build = await import(BUILD_PATH);
/**
* @type {() => Promise<ServerBuild>}
*/
const reimportServer = async () => {
const stat = fs.statSync(BUILD_PATH);
// use a timestamp query parameter to bust the import cache
return import(BUILD_PATH + "?t=" + stat.mtimeMs);
};
In ESM, there's no way to remove entries from the import
cache.
While our timestamp workaround works, it means that the import
cache will grow over time which can eventually cause Out of Memory errors.
If this happens, you can restart the dev server to restart with a fresh import cache. In the future, Remix may pre-bundle your dependencies to keep the import cache small.
Now that you have a way to bust the import cache for CJS or ESM, its time to put that to use by dynamically updating the server build within your app server. To detect when the server code changes, you can use a file watcher like chokidar:
import chokidar from "chokidar";
async function handleServerUpdate() {
build = await reimportServer();
}
chokidar
.watch(BUILD_PATH, { ignoreInitial: true })
.on("add", handleServerUpdate)
.on("change", handleServerUpdate);
Now's a good time to double-check that your app server is sending "ready" messages to the dev server when it initially spins up:
const port = 3000;
app.listen(port, async () => {
console.log(`Express server listening on port ${port}`);
if (process.env.NODE_ENV === "development") {
broadcastDevReady(build);
}
});
In manual mode, you also need to send "ready" messages whenever you re-import the server build:
async function handleServerUpdate() {
// 1. re-import the server build
build = await reimportServer();
// 2. tell dev server that this app server is now up-to-date and ready
broadcastDevReady(build);
}
Last step is to wrap all of this up in a development mode request handler:
/**
* @param {ServerBuild} initialBuild
*/
function createDevRequestHandler(initialBuild) {
let build = initialBuild;
async function handleServerUpdate() {
// 1. re-import the server build
build = await reimportServer();
// 2. tell dev server that this app server is now up-to-date and ready
broadcastDevReady(build);
}
chokidar
.watch(BUILD_PATH, { ignoreInitial: true })
.on("add", handleServerUpdate)
.on("change", handleServerUpdate);
// wrap request handler to make sure its recreated with the latest build for every request
return async (req, res, next) => {
try {
return createRequestHandler({
build,
mode: "development",
})(req, res, next);
} catch (error) {
next(error);
}
};
}
Awesome! Now let's plug in our new manual transmission when running in development mode:
app.all(
"*",
process.env.NODE_ENV === "development"
? createDevRequestHandler(build)
: createRequestHandler({
build,
mode: process.env.NODE_ENV,
})
);
For complete app server code examples, check out the express template or community examples.
When server code is re-imported, any server-side in-memory state is lost. That includes things like database connections, caches, in-memory data structures, etc.
Here's a utility that remembers any in-memory values you want to keep around across rebuilds:
// adapted from https://github.com/jenseng/abuse-the-platform/blob/main/app/utils/singleton.ts
// thanks @jenseng!
export function remember<T>(
key: string,
getValue: () => T
) {
const g = global as any;
g.__remember ??= {};
g.__remember[key] ??= getValue();
return g.__remember[key];
}
For example, to reuse a Prisma client across rebuilds:
import { PrismaClient } from "@prisma/client";
import { remember } from "~/utils/remember";
// hard-code a unique key so we can look up the client when this module gets re-imported
export const db = remember("db", () => new PrismaClient());