Dans les backends Node.js, la gestion des fichiers sert surtout à des besoins très concrets: charger une configuration, générer un export, écrire un log, traiter un upload temporaire ou stocker un artefact local. Le module fs est simple en apparence, mais le bon choix entre Promises, callbacks, code synchrone et streams change vite la lisibilité, les performances et la sécurité d’une API. Je vais donc aller droit au point utile: ce qu’il faut utiliser, quand, et surtout ce qu’il vaut mieux éviter.
Les repères utiles avant de manipuler des fichiers côté serveur
-
node:fs/promisesest mon point d’entrée par défaut pour un backend lisible et maintenable. - Les versions synchrones bloquent l’event loop et je les réserve aux scripts courts, au bootstrap ou aux tâches ponctuelles.
-
readFileetwriteFileconviennent bien aux petits fichiers, mais ils chargent tout en mémoire. - Au-delà de quelques dizaines de Mo, je passe presque toujours par des streams.
- Pour un chemin issu de l’utilisateur, je valide toujours le chemin final et je limite les permissions.
- Si le fichier doit être partagé par plusieurs instances, je réfléchis d’abord à une base de données ou à un stockage objet.
Ce que le module fs apporte vraiment à une API Node.js
La documentation officielle de Node.js présente node:fs comme une API de bas niveau inspirée de primitives POSIX. En pratique, je m’en sers pour des besoins précis dans une API: lire un fichier de configuration, écrire un log local, conserver un export ponctuel, produire un PDF temporaire ou gérer un répertoire de travail qui ne mérite pas encore une base de données.
Ce que j’évite, en revanche, c’est d’en faire la source de vérité d’un service distribué. Dès qu’une donnée doit être partagée entre plusieurs réplicas, requêtée, versionnée ou sauvegardée proprement, le disque local cesse d’être le bon centre de gravité. Le système de fichiers reste très utile, mais il faut le voir comme un outil d’infrastructure, pas comme un substitut de stockage métier.
Autrement dit, le bon usage de fs dans une API repose surtout sur une question de rôle: est-ce que le fichier est un artefact technique, ou est-ce que c’est la donnée principale? La réponse à cette question détermine presque tout le reste.
Choisir entre Promises, callbacks et code synchrone
Node.js expose trois formes d’accès au système de fichiers, et je ne les place pas du tout au même niveau. La documentation Node.js rappelle que les callbacks peuvent être préférables si l’on cherche la performance maximale, alors que les versions synchrones bloquent l’event loop. En backend, ce détail n’est pas théorique: il influence directement la latence sous charge.
| Forme | Quand je la choisis | Avantages | Limites |
|---|---|---|---|
node:fs/promises |
Endpoints HTTP, services métiers, code moderne avec async/await
|
Lisible, compose bien avec try/catch, facile à maintenir |
Légèrement plus d’overhead que les callbacks dans les cas extrêmes |
| Callbacks | Code très sensible à la perf ou au coût mémoire | Très efficaces, très proches de l’API native | Plus verbeux, moins agréable à composer |
| Synchrones | Scripts, CLI, bootstrap très court, tâches hors chemin critique | Simples à écrire et à lire | Bloque l’event loop et pénalise toutes les requêtes en cours |
Mon choix par défaut est clair: Promises pour l’API, synchrone seulement hors requête. Si j’écris un script de maintenance lancé une fois par jour, je peux accepter le synchrone. Si j’écris une route appelée cent fois par seconde, je l’évite sans hésiter.
La suite logique, ce n’est pas la théorie, mais la manière de lire et d’écrire un fichier proprement sans introduire de blocage inutile.

Lire et écrire un fichier sans bloquer le serveur
Pour des fichiers modestes, readFile et writeFile restent les outils les plus simples. La règle que je garde en tête est la suivante: si le fichier doit être chargé en entier en mémoire, il doit rester petit. La documentation Node.js précise d’ailleurs que ces méthodes lisent le contenu complet avant de le renvoyer, ce qui pèse vite sur la mémoire dès que la taille augmente.
import { readFile, writeFile, rename } from 'node:fs/promises';
async function updateConfig(filePath, patch) {
const raw = await readFile(filePath, 'utf8');
const current = JSON.parse(raw);
const next = { ...current, ...patch };
const tmpPath = `${filePath}.tmp`;
await writeFile(tmpPath, JSON.stringify(next, null, 2), {
encoding: 'utf8',
mode: 0o600
});
await rename(tmpPath, filePath);
}Ce pattern me plaît pour une raison simple: il limite les fichiers partiellement écrits. Si le processus tombe en plein milieu, je préfère laisser un fichier temporaire que corrompre directement la version finale. Pour un export ou un fichier de configuration, c’est souvent la différence entre un incident proprement gérable et un état incohérent difficile à diagnostiquer.
Quand le fichier peut déjà exister, je pense aussi au mode exclusif. Un flag: 'wx' me permet d’éviter un écrasement silencieux, ce qui est utile pour des uploads ou des artefacts uniques. Je peux alors traiter explicitement l’erreur au lieu de découvrir après coup qu’un fichier a été remplacé.
Dès que la taille grimpe ou que l’usage devient intensif, je quitte ce modèle simple pour passer à des flux continus.
Utiliser les streams quand le fichier devient gros
Les streams sont la bonne réponse quand je veux traiter un gros volume sans tout charger en RAM. Je les utilise pour des CSV volumineux, des backups, des médias, des journaux ou des exports lourds. Une règle empirique me sert de repère: en dessous d’environ 1 Mo, je peux rester simple; entre 1 et 50 Mo, je regarde la charge réelle; au-delà de 50 Mo, je passe presque toujours en stream. Ce n’est pas une loi, juste un seuil pratique qui évite les mauvaises surprises.
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
await pipeline(
createReadStream('./exports/big.csv'),
createWriteStream('./archive/big.csv')
);Le vrai avantage, c’est la mémoire. Le second, souvent sous-estimé, c’est la compatibilité naturelle avec le reste de Node.js: les requêtes HTTP, les réponses HTTP et beaucoup d’outils internes reposent eux aussi sur des streams. Quand je garde cette logique de bout en bout, je simplifie le chemin de bout en bout.
Je précise quand même une limite: les streams demandent un peu plus de discipline. Il faut penser aux erreurs, à la fermeture, au piping et à l’orchestration. En échange, on gagne un comportement beaucoup plus stable sur les gros fichiers. La question suivante devient alors moins technique et plus sensible: comment éviter qu’un chemin mal formé ou qu’une permission trop large n’ouvre une brèche?
Sécuriser les chemins et les permissions
La plupart des incidents liés à fs ne viennent pas du module lui-même, mais des entrées qu’on lui donne. Si un nom de fichier vient de l’utilisateur, je le considère comme hostile jusqu’à preuve du contraire. Le risque classique, c’est le path traversal: un chemin construit à la va-vite peut sortir du répertoire prévu et viser une zone que l’application ne devrait jamais toucher.
import path from 'node:path';
const baseDir = path.resolve('./private-data');
function resolveSafeFile(userFileName) {
const resolved = path.resolve(baseDir, userFileName);
if (!resolved.startsWith(baseDir + path.sep)) {
throw new Error('Chemin interdit');
}
return resolved;
}Je vais aussi plus loin sur les permissions. Pour des données sensibles, je préfère 0o600 sur les fichiers et 0o700 sur les dossiers privés; pour du contenu public, 0o644 peut suffire. Ce n’est pas du luxe: c’est une façon simple de réduire la surface d’exposition dès la création du fichier.
Un autre réflexe utile consiste à faire l’opération puis gérer l’erreur plutôt que de multiplier les pré-tests. Si un fichier n’existe pas, s’il existe déjà, ou si les permissions refusent l’accès, je traite ENOENT, EEXIST ou EACCES directement dans le flux. C’est plus fiable qu’un contrôle séparé qui peut devenir obsolète entre deux appels.
Une fois les chemins sécurisés, il reste encore un sujet qui semble anodin au premier regard et qui finit pourtant par peser en production: la surveillance des fichiers.
Surveiller les fichiers sans fragiliser la production
Je me sers de fs.watch() pour des tâches de confort opérationnel: recharger une configuration locale, réagir à la création d’un fichier d’import ou vider un cache technique. Ce n’est pas un mécanisme de vérité métier. Son comportement dépend de l’environnement, et il peut varier nettement selon le système de fichiers, le type de volume ou l’infrastructure d’exécution.
import { watch } from 'node:fs';
const watcher = watch('./config', (eventType, filename) => {
if (!filename) return;
console.log(`Changement détecté: ${String(filename)} (${eventType})`);
});
// Plus tard, au shutdown
// watcher.close();Quand la situation devient moins favorable, je sais aussi que watchFile() existe, mais je le vois comme un filet de secours: il repose sur du polling, donc il est plus lent et moins fiable que watch(). La bonne question n’est pas seulement “est-ce que ça marche sur ma machine?”, mais “est-ce que ça reste acceptable sur un volume réseau, dans un conteneur ou au redémarrage d’un service?”.
En pratique, si un changement de fichier déclenche un comportement critique, je préfère souvent un mécanisme explicite: message de queue, endpoint de reload, ou tâche planifiée. Le watching me sert pour l’ergonomie, pas pour verrouiller une règle métier.
Quand ce point est clarifié, on voit beaucoup mieux quand le disque local est utile et quand il faut en sortir.
Quand fs n’est pas la bonne solution
Il y a des cas où le système de fichiers reste très pratique, et d’autres où il devient un compromis trop fragile. Pour trancher rapidement, je compare souvent trois options.
| Solution | Idéale pour | Forces | Limites |
|---|---|---|---|
| Disque local | Cache transitoire, exports, artefacts techniques par instance | Simple, rapide, sans dépendance réseau | Non partagé, sauvegarde manuelle, peu adapté aux réplicas multiples |
| Base de données | Données métier, métadonnées, requêtes, transactions | Indexation, cohérence, recherche, contrôle d’accès plus fin | Moins adaptée aux gros binaires ou aux fichiers volumineux |
| Stockage objet | Uploads, médias, exports persistants, contenus distribués | Partage simple, durabilité, montée en charge plus naturelle | Intégration supplémentaire, coût et latence réseau |
Le critère qui me fait basculer le plus vite n’est pas la taille du fichier, mais le mode de déploiement. Si une API tourne avec plusieurs instances, si elle doit survivre à un redémarrage sans perte, ou si le contenu doit être partagé entre services, je ne fais pas confiance au disque local comme source principale. À l’inverse, pour un cache jetable, un export temporaire ou une génération locale que je peux régénérer, fs reste parfaitement défendable.
Dans beaucoup de backends, le meilleur schéma est hybride: métadonnées en base, fichier dans un stockage objet, et disque local réservé aux artefacts temporaires. C’est souvent plus robuste qu’un tout-fichier ou tout-base improvisé.
La checklist que j’utilise avant de livrer un traitement de fichiers
- J’utilise
node:fs/promisespar défaut dans le code applicatif. - Je garde le synchrone pour les scripts hors chemin critique.
- Je valide toujours le chemin final avant d’écrire ou de lire.
- Je choisis des permissions explicites au moment de créer le fichier.
- Je passe en stream dès que le volume commence à peser sur la mémoire.
- Je prévois le cas où le fichier est absent, déjà présent, verrouillé ou inaccessible.
Si je devais garder une seule règle, ce serait celle-ci: le module fs doit servir l’API, jamais la diriger. Dès qu’un fichier devient une donnée métier durable, je le traite comme un vrai système de stockage, avec ses contraintes de concurrence, de sécurité et de disponibilité. C’est cette discipline qui évite les arrière-pensées techniques et qui rend un backend vraiment solide.