Best Practices for the File System API
Production-grade patterns for Tauri v2 file system access – handling permissions, large files, errors, and security while keeping the frontend responsive
The file system plugin gives you direct disk access from a Tauri app. That power comes with sharp edges: one misconfigured permission, one forgotten OS-level restriction, or one huge file loaded entirely into memory can take down your app. This section walks through the patterns that keep your file operations safe, fast, and predictable across all platforms.
Handling Permissions
Tauri v2 uses a capability-based permission system. Every file operation the frontend performs must be explicitly allowed by a scope entry. Getting these scopes right is the single most important step for a reliable file system integration.
How the scope system works
When the frontend calls readFile('avatar.png', { baseDir: BaseDirectory.AppData }), the plugin checks whether the resolved path matches at least one allowed scope pattern. If none matches, the operation is rejected before it ever touches the OS.
The scope is defined in capability files inside src-tauri/capabilities/. A minimal fs:scope permission for reading an app-specific config directory looks like this:
{
"identifier": "fs:scope",
"allow": [
{ "path": "$APPDATA/config/*" }
]
}
The $APPDATA variable resolves to the platform-appropriate app data directory. Tauri also supports $HOME, $RESOURCE, $TEMP, and a few others.
Scope patterns use glob syntax:
The * wildcard matches any characters except path separators, so $APPDATA/config/* covers direct children of that folder but not nested subdirectories. For deep nesting, use $APPDATA/config/** which matches everything recursively.
Least privilege: start narrow, expand only when necessary
A common beginner mistake is adding a scope that allows everything:
{
"identifier": "fs:scope",
"allow": [
{ "path": "**/*" }
]
}
Do not use **/* in production without runtime guards:
Allowing any file path on the system means the frontend can read, write, or delete anything the user can access. A single cross-site scripting vulnerability or a malicious dependency could wipe user data. Always prefer targeted scopes and use runtime scope expansion for user-selected files.
Instead, enumerate the exact directories your app truly needs:
$APPDATA– configuration files, local databases$HOME/Documents– user-facing documents your app creates$RESOURCE– read-only bundled assets (limited write access on some platforms)
If your app lets users pick arbitrary files through a dialog, you do not need to pre-authorize the entire disk. Expand the scope at runtime after the user picks a path, which is covered in the Security section below.
Platform-specific permission layers
The Tauri scope is only one layer. Operating systems impose their own restrictions, and ignoring them causes "permission denied" errors that look like Tauri bugs.
When your app accesses shared storage directories like documents, downloads, or pictures, Android requires explicit permissions in AndroidManifest.xml:
<!-- gen/android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
Without these, the OS blocks the operation regardless of what your capability file allows.
OS restrictions trump Tauri scopes:
If you see PermissionDenied on Rust-side std::fs calls or Tauri plugin operations despite correct capability configuration, check the OS-level permissions first. On macOS, the app must also be granted Full Disk Access if you are accessing protected directories.
Path Handling and Cross-Platform Filesystem Access
The file system plugin provides two ways to reference files: base directory shortcuts and absolute paths resolved with the Path API. Choosing the right one avoids platform-specific breakage and keeps your code readable.
When to use base directories
Base directories let you write cross-platform code without hardcoding paths. BaseDirectory.AppData maps to C:\Users\Alice\AppData\Roaming on Windows and /Users/alice/Library/Application Support on macOS. Your code stays clean:
import { readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
async function loadConfig(): Promise<string> {
const contents = await readTextFile('config.json', {
baseDir: BaseDirectory.AppConfig,
});
return contents;
}
Use this pattern for any file that belongs to the application itself — settings, caches, logs, locally stored user data.
When to use the Path API
If you need to compose paths dynamically or work with directories outside the predefined base shortcuts, use the Path API to resolve the user's home directory or other known locations:
import { readTextFile } from '@tauri-apps/plugin-fs';
import { homeDir, join } from '@tauri-apps/api/path';
async function readUserFile(filename: string): Promise<string> {
const home = await homeDir();
const fullPath = await join(home, 'Documents', 'MyApp', filename);
return await readTextFile(fullPath);
}
The join function handles platform-specific separators, so Documents/MyApp/file.txt becomes the correct path on both Windows and Unix.
Do not concatenate paths with string addition:
Manual concatenation like home + '/Documents/' + filename fails on Windows where the separator is \. Always use join from the Path API to avoid subtle bugs.
Guarding against path traversal
The plugin automatically rejects paths containing ../ or leading /.. to prevent directory traversal attacks. You do not need to write your own sanitizer for this. However, you should still validate any path that comes from user input before passing it to the plugin. A path like /etc/passwd might be technically valid and allowed by the scope, but a user should not be able to trick your app into reading system files.
A safe pattern: pair the Dialog API with the file system. The user explicitly selects a file, and you only operate on that specific path. You know it is intentional.
Working with Large Files
Reading a multi-gigabyte file with readTextFile or readFile loads the entire content into memory. In a JavaScript context this can freeze the UI or crash the renderer process. For files larger than a few megabytes, stream the data in manageable chunks.
Streaming reads with the file handle
The open function returns a file handle that supports read into a caller-provided buffer. You control how much to read at a time:
import { open, BaseDirectory } from '@tauri-apps/plugin-fs';
async function processLargeLog() {
const file = await open('debug.log', {
read: true,
baseDir: BaseDirectory.AppData,
});
const stat = await file.stat();
const chunkSize = 64 * 1024; // 64 KB
let position = 0;
while (position < stat.size) {
const bytesToRead = Math.min(chunkSize, stat.size - position);
const buffer = new Uint8Array(bytesToRead);
await file.read(buffer);
// Process chunk — e.g., parse lines, send to a worker, or update a progress bar
position += bytesToRead;
}
await file.close();
}
This keeps memory usage constant regardless of file size. For very large files you can also use a Web Worker on the frontend to keep the UI thread responsive during processing, though the read call itself is asynchronous and does not block JavaScript execution.
Streaming writes
Writing large files follows the same principle: open with write: true, call write repeatedly with small buffers, and close when done:
import { open, BaseDirectory } from '@tauri-apps/plugin-fs';
async function generateReport(dataIterator: AsyncIterable<Uint8Array>) {
const file = await open('report.csv', {
write: true,
create: true,
truncate: true,
baseDir: BaseDirectory.AppData,
});
for await (const chunk of dataIterator) {
await file.write(chunk);
}
await file.close();
}
The create: true flag creates the file if it doesn't exist, and truncate: true clears any previous content before writing. For append-only logs, use append: true instead.
Closing the file handle is mandatory:
Failing to call close() can leave the file locked and prevent other processes from accessing it. Always close handles in a finally block or use a helper that guarantees cleanup.
Large files on the Rust side
When you write custom Tauri commands that use std::fs, the same memory discipline applies. Read with BufReader and process line by line or in fixed-size chunks rather than calling std::fs::read_to_string:
#[tauri::command]
async fn search_in_file(path: String, query: String) -> Result<Vec<String>, String> {
let file = std::fs::File::open(&path).map_err(|e| e.to_string())?;
let reader = std::io::BufReader::new(file);
let mut matches = Vec::new();
for line in reader.lines() {
let line = line.map_err(|e| e.to_string())?;
if line.contains(&query) {
matches.push(line);
}
}
Ok(matches)
}
BufReader reads the file in buffered chunks under the hood, so memory usage stays proportional to the longest line, not the entire file.
Error Handling
File system operations fail for many reasons: the path doesn't exist, the scope rejects it, the user revoked permission, the disk is full, or the OS blocks the operation. Handling these errors gracefully keeps your app usable rather than crashing it with a cryptic panic.
Catching errors on the frontend
Every plugin function returns a promise that rejects on failure. Wrap calls in try/catch and inspect the error to give the user a meaningful message:
import { readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
async function loadSettings() {
try {
const content = await readTextFile('settings.json', {
baseDir: BaseDirectory.AppData,
});
return JSON.parse(content);
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
console.warn('No settings file yet, using defaults.');
return {};
}
if (error instanceof Error && error.message.includes('scope')) {
// The file exists but we lack permission — this is a configuration bug
console.error('Scope misconfigured. Check your capability file.');
throw error;
}
// Unknown error — rethrow or fall back
throw error;
}
}
The plugin's error messages include keywords like not found, PermissionDenied, and path not allowed. Use these to distinguish recoverable conditions (missing file) from configuration errors (scope issue) from fatal disk problems.
Handling errors in Rust commands
If you write custom commands that bypass the plugin, you must handle std::io::Error explicitly. Never call .unwrap() on a file operation that can fail:
#[tauri::command]
fn delete_temp(path: String) -> Result<(), String> {
// Bad: std::fs::remove_file(&path).unwrap(); — crashes the app
std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path, e))
}
Returning Result<(), String> from a command lets the frontend catch the error as a rejected promise, just like plugin calls.
Test your error paths:
Temporarily rename a file, revoke a permission, or use an invalid path during development. If the app shows a generic "Something went wrong" message instead of a specific recovery action, your error handling is not yet complete.
User-facing error messages
The raw error string from the OS or plugin is not always suitable for end users. Map technical errors to plain-language messages:
- "Path not allowed in the configured scope" → "This folder is not currently accessible. Try selecting it again through the file picker."
- "No space left on device" → "The disk is full. Free up some space and try again."
- "Permission denied" → "The app does not have permission to access this location. Check your system privacy settings."
Keep the technical details in logs for debugging, but show the user an actionable next step.
Security Considerations
File system access in a desktop app carries the same risks as any native application, with the added challenge that the frontend is a web-like environment. A single unchecked path or an overly broad scope can turn into a serious data exposure.
Dynamic scope expansion for user-selected files
The safest way to handle arbitrary file access is to let the user pick the file with the Dialog API, then grant access to that specific file or its parent directory at runtime. This avoids baking a universal **/* scope into your capability file.
First, set up a Rust command that accepts a path and expands the scope:
// src-tauri/src/lib.rs
use tauri::Manager;
use tauri_plugin_fs::FsExt;
#[tauri::command]
fn allow_selected_directory(app: tauri::AppHandle, path: String) -> Result<(), String> {
let scope = app.fs_scope();
scope.allow_directory(&path, false).map_err(|e| e.to_string())?;
Ok(())
}
Then call this command after the user picks a file:
// React component
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { readTextFile } from '@tauri-apps/plugin-fs';
async function openUserFile() {
const selected = await open({
multiple: false,
title: 'Choose a file to open',
});
if (!selected) return;
// selected is the full path as a string
// Extract the parent directory to grant access
const parentDir = selected.substring(0, selected.lastIndexOf('/'));
await invoke('allow_selected_directory', { path: parentDir });
// Now the file system plugin can read this path
const content = await readTextFile(selected);
// Use content...
}
The allow_directory call with false as the second argument grants access to files directly inside that directory but not to subdirectories. If the user picks a deeply nested file, you may want to allow the immediate parent only, maintaining least privilege.
Do not trust user-supplied paths without validation
Even though the plugin rejects ../, a user could still supply a path like /etc/passwd if your scope allows it. Always validate that a given path makes sense for your app's domain. If you expect only .json files, check the extension. If you expect only files within the user's Documents folder, verify the path starts with that directory.
Separate app data from user documents
Store your application's internal state (config, caches, logs) in BaseDirectory.AppData or BaseDirectory.AppConfig. Reserve user-visible directories like Documents or Desktop for files the user explicitly creates or exports. This separation prevents your app from accidentally cluttering the user's folders and limits the blast radius of a bug that writes unintended data.
Avoid executing or interpreting file content
Never pass the contents of a file directly to eval(), Function(), or innerHTML. Treat every file as potentially malicious. If you must parse configuration files, use a safe parser for the format (JSON.parse is fine, but YAML.parse with exec support is dangerous). For image files, rely on the browser's built-in rendering rather than custom decoders.
Performance and Platform-Specific Notes
A file system integration that works on your development machine can still fail on a user's device. Platform differences and install modes introduce subtle constraints.
Write permissions in the Resources directory
On Windows, apps installed with the MSI or NSIS installer in perMachine or both mode cannot write to the $RESOURCE directory without administrator privileges. On Linux and macOS, $RESOURCE is read-only in all configurations. Do not store mutable app data there — use $APPDATA instead.
Async operations and UI responsiveness
All plugin functions are asynchronous. Calling them from a React event handler will not freeze the UI, but chaining many rapid operations (for example, iterating over thousands of files) can still degrade perceived performance. Batch operations where possible, show progress indicators, and consider delegating heavy lifting to a Rust command that reports progress via Tauri events.
Testing on all target platforms
The same capability file that works on Linux might fail on Windows because $HOME/Documents resolves differently, or on macOS because the sandbox blocks the path entirely. Run your permission-sensitive code paths on each platform you intend to support, and build a test checklist that covers:
- Reading from and writing to app data directories
- Picking a file with the dialog and reading it
- Writing a new file to a user-chosen location
- Handling a full disk scenario (use a small RAM disk for testing)
- Revoking a platform permission while the app is running
Summary
The file system plugin gives you a direct pipeline from the frontend to the disk. The best practices boil down to three rules:
- Scope tightly. Never ship a
**/*capability file. Grant access to the specific directories your app needs, and expand scope at runtime only after explicit user action. - Stream large data. Keep memory usage predictable by reading and writing in chunks through the file handle API, both in JavaScript and in Rust commands.
- Fail gracefully. Wrap every file operation in error handling that distinguishes between missing files, permission problems, and disk errors, and present the user with an actionable next step.
When you encounter a permission error that the scope configuration cannot explain, the next place to look is the platform’s own privacy and security layers — Android manifest entries, iOS privacy manifests, and macOS entitlements.
For specific error patterns and debugging techniques, the Common Errors and Troubleshooting section covers typical failure modes and how to diagnose them.