Quick answer: To read a JSON file in Node.js, call
JSON.parse(fs.readFileSync(path, "utf8")), orJSON.parse(await fs.promises.readFile(path, "utf8"))in async code. To write one, callfs.writeFileSync(path, JSON.stringify(data, null, 2)).require()andimportalso load JSON files, but they cache the result, so use them only for data that does not change while the process runs.
A feature flag file gets edited on the server, the admin reloads the page, and nothing changes. The code reads flags.json with require(), which read the file exactly once, at startup, and will never read it again. That is the most common JSON-file bug in Node, and it is a good reason to know all four ways to load a file rather than just the shortest one.
Explore the resulting JSON in the JSON Viewer, or select nested fields with the JSONPath Query Tester.
Which Method Should You Use?
| Method | Behavior | Best for | Watch out |
|---|---|---|---|
require('./x.json') | Sync, cached | Static config loaded once at startup | CommonJS only; cached forever |
import x from './x.json' with { type: 'json' } | Sync at load, cached | Static config in ES modules | Needs the import attribute |
fs.readFileSync + JSON.parse | Sync, fresh each call | CLI tools, scripts, startup code | Blocks the event loop |
fs.promises.readFile + JSON.parse | Async, fresh each call | Servers, anything at request time | Needs await |
Method 1: require() in CommonJS
const config = require("./config.json");
console.log(config.port); // 3000
Node resolves the path relative to the current file, reads it, parses it, and hands back the object. No fs, no JSON.parse. For package.json or a config file that is part of your source tree, this is fine.
The caching trap
Every module Node loads goes into require.cache, JSON files included. A second require("./config.json") anywhere in the process returns the same object without reading the disk. Two consequences:
- Edits to the file after startup are never seen.
- If one module does
config.port = 4000, every other module that required the file sees4000too, because they all share one object.
You can delete the cache entry with delete require.cache[require.resolve("./config.json")], but if you find yourself doing that, the file is not static and you should be using fs.
Method 2: import in ES Modules
In an ES module (a .mjs file, or a project with "type": "module" in package.json), a bare import of a JSON file is an error. You must add an import attribute that tells Node the module is JSON:
import config from "./config.json" with { type: "json" };
console.log(config.port);
Older Node releases used the keyword assert instead of with; the with form is the standardized one and the one to write today. If you see TypeError [ERR_IMPORT_ATTRIBUTE_MISSING], the attribute is absent. If you see a parse error on the word with itself, the Node version is too old for the new syntax.
The same caching rule applies: ES modules are evaluated once, so the data is a startup snapshot. Dynamic await import("./x.json", { with: { type: "json" } }) is also cached by URL.
If importing works locally but fails after building or deploying, use these four JSON import attribute checks to separate emitted syntax, default exports, asset delivery, and cache behavior before changing the loading method.
Method 3: fs.readFileSync + JSON.parse
const fs = require("node:fs");
const path = require("node:path");
const file = path.join(__dirname, "data", "users.json");
const users = JSON.parse(fs.readFileSync(file, "utf8"));
This reads the disk every time, so it always reflects the current file. It blocks the event loop while the read happens, which is irrelevant in a CLI script and harmful inside a request handler on a busy server.
Pass "utf8". Without an encoding, readFileSync returns a Buffer. JSON.parse will call toString() on it and usually get away with it, but being explicit avoids surprises with other encodings. If the file begins with a UTF-8 byte order mark, JSON.parse fails with an unexpected token error on the invisible first character; strip it with text.replace(/^/, "") or fix the file, as covered in the UTF-8 and BOM guide.
Method 4: fs/promises for Async Code
import { readFile } from "node:fs/promises";
async function loadUsers(file) {
const text = await readFile(file, "utf8");
return JSON.parse(text);
}
const users = await loadUsers(new URL("./data/users.json", import.meta.url));
This is the default choice for anything that runs while a server is serving. It does not block, it reads fresh data, and the error handling is a normal try/catch around await. The callback form fs.readFile(file, "utf8", (err, text) => ...) still exists and still works, but the promise API composes better.
Resolving the Path Correctly
fs resolves relative paths against process.cwd(), the directory the process was started from, not the directory of the script. Running node src/app.js from the project root and node app.js from inside src give different results for "./data.json". Anchor the path to the file instead:
// CommonJS
const file = path.join(__dirname, "data.json");
// ES modules
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = join(__dirname, "data.json");
// ES modules, shorter: fs accepts a file URL directly
const file = new URL("./data.json", import.meta.url);
Recent Node versions also expose import.meta.dirname, which removes the fileURLToPath dance; check that your minimum supported version has it before relying on it.
Writing a JSON File
import { writeFile } from "node:fs/promises";
const data = { port: 3000, features: ["search", "export"] };
await writeFile("config.json", JSON.stringify(data, null, 2) + "\n", "utf8");
The second argument to JSON.stringify is a replacer (leave it null); the third is the indent, either a number of spaces or a string such as "\t". Two spaces is the common convention for files that live in a repository. Appending "\n" gives the file a trailing newline, which keeps git diffs clean and linters quiet. The JSON.stringify guide covers the replacer and what happens to undefined, functions, and dates.
Atomic writes: temp file plus rename
writeFile truncates the file first and then writes. If the process is killed, or the disk fills, between those two steps, you are left with an empty or half-written file, and the next read fails with Unexpected end of JSON input. For anything you cannot regenerate, write to a temporary file in the same directory and rename it into place. A rename on the same filesystem is atomic, so readers see either the old file or the new one, never a partial one:
import { writeFile, rename } from "node:fs/promises";
import { randomUUID } from "node:crypto";
async function writeJsonAtomic(file, data) {
const tmp = file + "." + randomUUID() + ".tmp";
await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", {encoding: "utf8", flag: "wx", mode: 0o600});
await rename(tmp, file);
}
Keep the temp file in the same directory as the target. A cross-filesystem rename can fail with EXDEV; it does not automatically become a safe copy. This minimal example omits failed-candidate cleanup, metadata preservation, synchronization, and writer coordination. Atomic replacement alone does not prevent stale snapshots from overwriting each other. Use the six JSON file write checks to test unique staging files, the complete read-modify-write cycle, durability, and recovery before using JSON as persistent application state.
Error Handling: ENOENT and SyntaxError
Reading a JSON file can fail in two distinct ways, and they deserve different handling:
import { readFile } from "node:fs/promises";
async function readJson(file, fallback) {
let text;
try {
text = await readFile(file, "utf8");
} catch (err) {
if (err.code === "ENOENT" && fallback !== undefined) return fallback;
throw err; // EACCES, EISDIR, etc. are real problems
}
try {
return JSON.parse(text);
} catch (err) {
throw new Error("Invalid JSON in " + file + ": " + err.message);
}
}
A missing file (ENOENT) is often expected: a settings file the user has not created yet. A corrupt file is never expected and should fail loudly with the file name attached, because JSON.parse alone reports a position but not which file. When that happens, paste the file into JSON Prettifier to get the exact line and column; the JSON parse error guide lists what each message means.
Large Files: When JSON.parse Is the Wrong Tool
JSON.parse needs the entire document in memory as one string, and V8 caps string length at roughly 512 MB on 64-bit builds. Well before that limit, parsing a few hundred megabytes takes seconds and holds two copies of the data (text and object) at once. Options, in order of effort:
- Switch the format to JSON Lines. One object per line means you can read the file with
readlineand parse each line independently, with constant memory. See JSON Lines explained. - Use a streaming parser. The
stream-jsonpackage emits objects from inside a large array as they are parsed, without loading the whole file. - Split the file at the producer, or paginate the data source.
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const rl = createInterface({ input: createReadStream("events.jsonl", "utf8") });
for await (const line of rl) {
if (!line.trim()) continue;
const event = JSON.parse(line);
// handle one event at a time
}
The JSON file size limits post goes into the numbers for Node, browsers, and common API gateways.
Common Mistakes
- Using require() for data that changes. The cache means you are reading a snapshot. Use
fs. - Mutating a required JSON object. Every module shares it. Clone with
structuredClone()if you must change it locally. - Relative paths with fs. They resolve against the working directory, not the script. Use
__dirnameorimport.meta.url. - Forgetting the encoding. You get a
Buffer, andconsole.logshows bytes instead of text. - Hand-editing a file and leaving a trailing comma. JSON does not allow it and Node will refuse the file. Validate after editing; deleting the comma is a thirty-second job.
- Writing with writeFileSync inside a request handler. It blocks every other request until the disk write completes. Use the promise API.