Manual mode
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.
Mental model for 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 processes: the Remix compiler and your app server.
Check out our video “Mental model for the new dev flow 🧠” for more details.
Previously, we referred to the Remix compiler as the “new dev server” or the “v2 dev server”. Technically, remix dev
is a thin layer around the Remix compiler that does include a tiny server with a single endpoint (/ping
) for coordinating hot updates. But thinking of remix dev
as a “dev server” is unhelpful and wrongly implies that it is replacing your app server in dev. Rather than replacing your app server, remix dev
runs your app server alongside the Remix compiler so you get the best of both worlds:
- Hot updates managed by the Remix compiler
- Real production code paths running in dev within your app server
Learning to drive stick
When you switch on manual mode with --manual
, you take on some new responsibilities:
- Detect when server code changes are available
- Re-import code changes while keeping the app server running
- Send “ready” message to the Remix compiler after those changes are picked up
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.
1.a CJS: require
cache busting
CommonJS 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!
1.b ESM: import
cache busting
Unlike 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 remix dev
to start again with a fresh import cache. In the future, Remix may pre-bundle your dependencies to keep the import cache small.
2. Detecting server code changes
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);
3. Sending the “ready” message
Now’s a good time to double-check that your app server is sending “ready” messages to the Remix compiler 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 Remix that this app server is now up-to-date and ready
broadcastDevReady(build);
}
4. Dev-aware request handler
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 Remix 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 our templates or community examples.
Keeping in-memory server state across rebuilds
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());