Text editor on Electron freezes for a few seconds when opening a file larger than 50 MB. Users complain about lag when dragging files. The cause is unoptimized IPC and lack of size pre-check. Both frameworks — Electron and Tauri — have their nuances. Let's figure out how to properly implement file system access in a desktop app to avoid such issues.
Problems We Solve
Path traversal — when the renderer sends a path like ../../etc/passwd. In Electron, this is solved by validation on the main process side. In Tauri, by scoping in capabilities/default.json. In one project, we discovered the client was passing absolute paths without validation — fixed it in an hour, but could have lost data. Learn more about Path traversal on Wikipedia.
Inefficient IPC for large files — transferring files over 100 MB via ipcRenderer.invoke blocks the renderer. Solution: use streams or transfer ArrayBuffer via SharedArrayBuffer. Electron now supports MessagePort for transferring large volumes without copying.
Difficulty with change monitoring — fs.watch in Node.js is unstable on macOS — it misses events under heavy load. Alternative: polling at 1000 ms intervals or using chokidar with awaitWriteFinish. For Tauri, the tauri-plugin-fs watch uses inotify on Linux and FSEvents on macOS.
How to Ensure File Access Security?
In Electron, all file I/O happens in the main process. The renderer requests operations via IPC. This prevents direct UI attacks. Use contextBridge for isolation — do not export anything from preload except safe methods. In Tauri, the permission model is even stricter: every action (read, write, delete) must be declared in capabilities/default.json. Specify a scope for specific directories: app-data, documents, downloads. Never grant access to the entire file system. With 5+ years of experience and over 100 projects delivered, we guarantee secure implementations.
Example of permission configuration in Tauri
{
"permissions": [
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-watch",
"fs:scope-app-data-recursive",
"fs:scope-document-recursive",
"dialog:allow-open",
"dialog:allow-save"
]
}
Implementation in Electron
The classic approach is a FileSystemService class in the main process. It uses fs/promises and fs for file operations, and dialog for native open/save interfaces. Important: warn the user about files >50 MB to avoid hanging the process. For file lists, use hidden file filtering and sorting: directories first, then alphabetically.
// main/fs-service.js
const fs = require('fs/promises');
const fsSync = require('fs');
const path = require('path');
const { app, dialog } = require('electron');
class FileSystemService {
async openFileDialog(win, options = {}) {
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: options.filters ?? [{ name: 'All Files', extensions: ['*'] }],
...options
});
if (result.canceled || result.filePaths.length === 0) return null;
return this.readFile(result.filePaths[0]);
}
async openFolderDialog(win) {
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] });
if (result.canceled) return null;
return result.filePaths[0];
}
async readFile(filePath) {
const stat = await fs.stat(filePath);
if (stat.size > 50 * 1024 * 1024) {
throw new Error(`File too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB`);
}
const content = await fs.readFile(filePath, 'utf-8');
return { path: filePath, name: path.basename(filePath), ext: path.extname(filePath).slice(1), content, size: stat.size, modified: stat.mtimeMs };
}
async saveFile(win, content, currentPath = null) {
let savePath = currentPath;
if (!savePath) {
const result = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), 'untitled.txt') });
if (result.canceled) return null;
savePath = result.filePath;
}
await fs.writeFile(savePath, content, 'utf-8');
return savePath;
}
async listDirectory(dirPath, options = {}) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const items = await Promise.all(entries.filter(e => options.showHidden || !e.name.startsWith('.')).map(async (entry) => {
const fullPath = path.join(dirPath, entry.name);
let stat;
try { stat = await fs.stat(fullPath); } catch { return null; }
return { name: entry.name, path: fullPath, isDirectory: entry.isDirectory(), size: entry.isFile() ? stat.size : 0, modified: stat.mtimeMs, ext: entry.isFile() ? path.extname(entry.name).slice(1) : null };
}));
return items.filter(Boolean).sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; return a.name.localeCompare(b.name); });
}
async copyDirectory(src, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
await Promise.all(entries.map(entry => {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
return entry.isDirectory() ? this.copyDirectory(srcPath, destPath) : fs.copyFile(srcPath, destPath);
}));
}
watchFile(filePath, callback) {
const watcher = fsSync.watch(filePath, { persistent: false }, (eventType) => callback({ eventType, path: filePath }));
return () => watcher.close();
}
watchDirectory(dirPath, callback) {
const watcher = fsSync.watch(dirPath, { recursive: true, persistent: false }, (eventType, filename) => {
if (filename) callback({ eventType, path: path.join(dirPath, filename), filename });
});
return () => watcher.close();
}
getAppPaths() {
return { userData: app.getPath('userData'), documents: app.getPath('documents'), downloads: app.getPath('downloads'), temp: app.getPath('temp'), home: app.getPath('home') };
}
}
module.exports = new FileSystemService();
Drag & drop is implemented by handling the drop event in the renderer with f.path (Electron adds this property). To avoid overloading the main process, pass only metadata via IPC and perform reading on demand.
Implementation in Tauri
Include the plugins tauri-plugin-fs and tauri-plugin-dialog. All file operations require explicit permissions in capabilities/default.json.
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
Example of reading and writing:
// renderer/api/fs.ts
import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
import { open, save } from '@tauri-apps/plugin-dialog';
export async function openAndReadFile() {
const selected = await open({ multiple: false });
if (!selected) return null;
const content = await readTextFile(selected as string);
return { path: selected as string, content };
}
export async function saveToFile(content: string, currentPath?: string) {
const filePath = currentPath ?? await save({ filters: [{ name: 'Text', extensions: ['txt'] }] });
if (!filePath) return null;
await writeTextFile(filePath as string, content);
return filePath;
}
For binary files, use readFile and writeFile from the same plugin — they work with Uint8Array.
Why Tauri is Faster Than Electron?
Tauri compiles a binary with Rust and uses the system WebView rather than embedded Chromium. App size is 2-3 times smaller, and memory consumption is up to 70% lower. However, Tauri requires declaring all operations in capabilities, which makes the code safer but increases setup time slightly.
Comparison of Electron and Tauri for File Access
| Characteristic | Electron | Tauri |
|---|---|---|
| API | Node.js fs (full access) | tauri-plugin-fs (scoped) |
| Security | Requires explicit process separation | Built-in permission model |
| App Size | >100 MB (includes Chromium) | <10 MB |
| Performance | Medium (due to Chromium) | High (native code) |
| Binary files | via Buffer and ArrayBuffer | via Uint8Array, conversion |
| File monitoring | fs.watch / fs.watchFile | tauri-plugin-fs watch |
| Cross-platform | Windows, macOS, Linux | Windows, macOS, Linux (iOS, Android in development) |
Process and Scope of Work
- Analysis — We study your current app, file access requirements, identify vulnerabilities. We create a data flow map.
- Design — We develop an IPC or permissions scheme, select libraries, design an API.
- Implementation — We write code with unit testing (jest for Node.js, vitest for Tauri). We use the Repository pattern to isolate business logic.
- Testing — We test with real files (up to 10 GB), simulate path traversal attacks, test on Windows, macOS, Linux.
- Deployment — We build binaries, configure auto-update, document the API.
We use Electron 28+, Tauri 2, TypeScript, ESLint, Prettier, Husky.
Case: For one startup, we implemented a file manager on Electron capable of opening files up to 4 GB without freezing. The solution — chunked transfer via IPC with a progress bar and on-demand abort. Load time for a 500 MB file decreased from 12 to 3 seconds (a 75% reduction). Request an audit of your app and we'll identify vulnerabilities in one day. Get an estimate from $500 for a basic setup.
Estimated Timelines
| Stage | Time |
|---|---|
| Analysis and design | 1 day |
| Basic access (read/write/dialogs) | 2-3 days |
| Monitoring and binary files | 1-2 days |
| Testing and debugging | 1-2 days |
| Integration into existing project | from 1 day |
Cost is calculated individually. Contact us for a consultation. Order custom file access development and we'll estimate your project in one day.







