Making Requests
How to send HTTP requests from the frontend of a Tauri v2 application using the HTTP plugin
To communicate with a remote server from a Tauri application, you use the HTTP plugin’s fetch function. The function is deliberately close to the standard browser fetch API, but it runs through the Tauri backend instead of the browser’s networking layer. That means requests originate from the Rust side, which avoids CORS restrictions entirely and gives you control over which domains the application is allowed to talk to.
HTTP requests are blocked by default:
Tauri v2 does not allow any network requests unless the HTTP plugin is installed and the target URLs are explicitly permitted in the capability configuration. Forgetting this step is the most common reason requests silently fail.
Prerequisites
The HTTP plugin must be added to both the Rust backend and the JavaScript frontend. If you have not yet installed it, follow the setup steps in the Introduction to the HTTP Plugin section. At minimum, your project needs:
tauri-plugin-httpinCargo.tomland registered inlib.rs@tauri-apps/plugin-httpinstalled as an npm dependency- Permission entries in
src-tauri/capabilities/default.jsonthat grant access to the domains you intend to contact
The code snippets in this document assume the plugin is already installed and the fetch import is available.
The fetch function
import { fetch } from '@tauri-apps/plugin-http';
fetch accepts a URL string and an optional configuration object. It returns a Promise<Response>, just like the browser fetch. The difference is that the networking is handled by the Rust backend, and the request will be rejected if the URL does not match the allowed scope in your capabilities.
const response = await fetch('https://api.example.com/data.json');
console.log(response.status);
Every call to fetch is subject to the permission rules. If the URL is not allowed, the promise rejects with an access-denied error before any network activity occurs.
URL permission scope
The HTTP plugin uses a scope system based on glob patterns. You must define which URLs the application is allowed to access. This is done inside a capability file, typically src-tauri/capabilities/default.json.
{
"permissions": [
{
"identifier": "http:default",
"allow": [{ "url": "https://api.example.com/**" }],
"deny": [{ "url": "https://api.example.com/internal/**" }]
}
]
}
The http:default permission set enables all the fetch sub-commands (send, read body, cancel, etc.) but does not grant access to any specific domain. The allow and deny arrays define the URL patterns that are permitted or explicitly blocked. Patterns support glob wildcards:
*matches a single path segment**matches everything, including nested paths?matches a single character
Scopes are evaluated in order:
Rules are checked top to bottom. If a URL matches a deny pattern, it is blocked even if a later allow would include it. Always place deny patterns after broader allow patterns only if you want exceptions; the safest pattern is to specify your allowed domains and leave deny empty unless you need to carve out a specific sub-path.
If your application needs to contact multiple unrelated APIs, list each one under allow. A request to any URL not covered by an allow rule will throw an error. This prevents a compromised frontend from calling arbitrary servers.
Sending a GET request
A GET request fetches data without altering server state. The simplest form uses only the URL:
// src/App.tsx
import { fetch } from '@tauri-apps/plugin-http';
import { useState, useEffect } from 'react';
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
function App() {
const [post, setPost] = useState<Post | null>(null);
useEffect(() => {
async function loadPost() {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts/1'
);
const data: Post = await response.json();
setPost(data);
}
loadPost();
}, []);
return (
<div>
{post && <h1>{post.title}</h1>}
</div>
);
}
export default App;
The response.json() method reads the body and parses it as JSON. You must call exactly one body consumption method (json, text, bytes) per response — the body is a stream that can only be read once. If you need both the raw text and the parsed JSON, read it as text first and then parse it yourself with JSON.parse.
The response is not a web Response:
The Response object from the HTTP plugin implements the same interface as the standard web Response, but it is backed by Rust’s reqwest library. CORS headers are irrelevant; Tauri bypasses the browser’s networking entirely.
Sending a POST request
A POST request sends data to the server, typically to create a resource. You supply the HTTP method, headers, and a body.
// src/CreateUser.tsx
import { fetch } from '@tauri-apps/plugin-http';
import { useState } from 'react';
function CreateUser() {
const [status, setStatus] = useState<string>('');
async function handleSubmit() {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Jane Doe',
email: 'jane@example.com',
}),
});
if (response.ok) {
setStatus('User created');
} else {
setStatus(`Error: ${response.status}`);
}
}
return (
<div>
<button onClick={handleSubmit}>Create User</button>
<p>{status}</p>
</div>
);
}
export default CreateUser;
The body field accepts a string, a Uint8Array, or an ArrayBuffer. If you pass a plain JavaScript object, it will not be automatically serialized to JSON — you must call JSON.stringify yourself and set the appropriate Content-Type header. The Rust backend sends the body as-is.
Forgetting Content-Type header:
Sending a JSON string without the Content-Type: application/json header will cause many servers to reject the request or misinterpret the body. Tauri does not auto-detect the content type.
Sending a PUT request
PUT replaces an existing resource entirely. The structure is identical to POST; only the method name changes:
const response = await fetch('https://api.example.com/users/42', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Jane Doe',
email: 'jane.doe@example.com',
}),
});
The server expects a complete representation of the resource. If you only need to update a subset of fields, use PATCH instead (same syntax, just change the method). A PUT request that leaves out a field typically resets that field to its default value on the server.
Sending a DELETE request
DELETE removes a resource. It rarely needs a body:
const response = await fetch('https://api.example.com/users/42', {
method: 'DELETE',
});
if (response.ok) {
console.log('User deleted');
}
Some APIs return the deleted resource in the response body. You can read it with response.json() if needed. A 204 No Content response is common and indicates success with no body — calling response.json() on a 204 will throw an error, so check response.status first if the server’s behavior is unknown.
Configuring requests
Beyond method and body, the second argument to fetch accepts several optional properties that control how the request behaves.
Headers
Headers are specified as a plain object with string keys and values:
const response = await fetch('https://api.example.com/data', {
headers: {
'Authorization': 'Bearer tok_abc123',
'Accept': 'application/json',
'X-Request-ID': 'req-001',
},
});
All header names are case-insensitive on the server side, but it is conventional to write them in capitalized-hyphenated form. The HTTP plugin passes headers directly to the Rust reqwest client; no normalization occurs that differs from standard HTTP.
Timeouts
A per-request timeout (in milliseconds) prevents a request from hanging indefinitely:
const response = await fetch('https://api.example.com/slow', {
timeout: 5000,
});
If the server does not respond within the specified window, the promise rejects. There is also a connectTimeout option, available under the ClientOptions that can be passed alongside RequestInit, but timeout is the most commonly used.
const response = await fetch('https://api.example.com/slow', {
connectTimeout: 3000, // timeout only for the TCP handshake
});
No default timeout:
Unlike some HTTP libraries, Tauri’s fetch has no built-in timeout. A request to an unresponsive server will wait until the operating system’s TCP timeout kicks in, which can take minutes. Always set a timeout for any request that blocks the user interface.
Redirect behavior
By default, the HTTP client follows up to 10 redirects. You can change this with maxRedirections:
// Follow no redirects
const response = await fetch('https://api.example.com/old-path', {
maxRedirections: 0,
});
Setting maxRedirections to 0 causes a redirect response (301, 302, etc.) to be returned as-is instead of being followed. This is useful when you need to inspect the Location header yourself. Each redirect is validated against your capability scope — a redirect to a domain not in your allow list will cause the request to fail.
Proxies
You can route requests through an HTTP or HTTPS proxy:
const response = await fetch('https://api.example.com/data', {
proxy: {
all: 'http://proxy.company:8080',
},
});
The proxy object accepts all (for all traffic), http (for HTTP only), and https (for HTTPS only). Each can be a URL string or a ProxyConfig object with basic authentication.
const response = await fetch('https://api.example.com/data', {
proxy: {
https: {
url: 'http://proxy.company:8080',
basicAuth: { username: 'user', password: 'pass' },
},
},
});
The noProxy field on a ProxyConfig accepts a comma-separated list of hostnames or IPs that should bypass the proxy.
Dangerous settings
Two options exist for development or internal environments where TLS certificate verification must be skipped. These should never be enabled in production:
const response = await fetch('https://localhost:8443/data', {
danger: {
acceptInvalidCerts: true,
acceptInvalidHostnames: true,
},
});
acceptInvalidCerts disables certificate chain validation. acceptInvalidHostnames disables hostname verification against the certificate’s SAN. Both are false by default and exist solely for testing against self-signed certificates on local servers.
Never ship with danger options enabled:
Disabling TLS verification allows a man-in-the-middle to intercept and read all traffic. These options exist for local development only. If you find yourself needing them in production, your certificate infrastructure is misconfigured.
Reading the response
The Response object provides several methods and properties. Working with Responses focuses on parsing bodies, headers, and error status.
| Property / Method | Description |
|---|---|
status | HTTP status code (e.g., 200, 404) |
statusText | Status text string (e.g., "OK") |
ok | true if status is between 200 and 299 |
headers | A Headers object (case-insensitive) |
url | Final URL after redirects |
response.text() | Promise that resolves with the body as a string |
response.json() | Promise that resolves with the body parsed as JSON |
response.bytes() | Promise that resolves with the body as a Uint8Array |
const response = await fetch('https://api.example.com/data');
console.log(response.status); // 200
console.log(response.ok); // true
const contentType = response.headers.get('content-type');
console.log(contentType); // "application/json; charset=utf-8"
const text = await response.text();
const parsed = JSON.parse(text);
Calling json() after text() will throw because the body stream is already consumed. If you need both raw text and parsed data, read text() first and then call JSON.parse on the result.
For binary data (images, files), use bytes():
const response = await fetch('https://api.example.com/photo.png');
const bytes = await response.bytes();
const blob = new Blob([bytes], { type: 'image/png' });
const url = URL.createObjectURL(blob);
// Use url in an <img> tag
No extra decoding step required:
The bytes() method returns the raw bytes directly. Unlike the browser fetch, you do not need to call response.arrayBuffer() then wrap it in a Uint8Array. The plugin does that for you.
Error handling
Errors from the HTTP plugin fall into two categories: scope denials and network errors.
A scope denial occurs when the requested URL does not match any allow pattern in the capability configuration. The promise rejects with an error whose string message states "url not allowed on the scope". This happens synchronously from the JavaScript perspective — no network round-trip occurs.
Network errors (DNS failures, connection refused, timeouts) reject the promise with an error object that contains a message describing what went wrong. The exact shape depends on the underlying reqwest error.
try {
const response = await fetch('https://blocked.example.com/data');
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const data = await response.json();
} catch (error) {
if (typeof error === 'string' && error.includes('url not allowed')) {
console.error('This domain is not permitted in the capabilities file.');
} else {
console.error('Network or server error:', error);
}
}
Always handle both the rejected promise and non-2xx status codes. The fetch promise only rejects on network-level failures and scope denials; a 404 or 500 response still resolves successfully, and you must inspect response.ok or response.status to detect it.
Scope errors happen before DNS resolution:
The URL is checked against your allowed patterns before any network activity. If a request to a legitimate domain is failing with "url not allowed", the problem is in your capabilities configuration, not in the network.
Using fetch from Rust code
The HTTP plugin also re-exports the reqwest crate for use directly from the Rust backend. This is useful when the request needs to happen in a Tauri command rather than in the frontend, or when you need access to reqwest features that the JavaScript API does not expose (like streaming bodies).
// src-tauri/src/main.rs
use tauri_plugin_http::reqwest;
#[tauri::command]
async fn fetch_from_backend() -> Result<String, String> {
let res = reqwest::get("https://api.example.com/status")
.await
.map_err(|e| e.to_string())?;
let body = res.text().await.map_err(|e| e.to_string())?;
Ok(body)
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_http::init())
.invoke_handler(tauri::generate_handler![fetch_from_backend])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Rust-side requests are subject to the same scope rules defined in the capabilities file. The domain you are contacting must be listed in the allow array, or the request will be blocked at the plugin level — even from the backend.
The reqwest re-export is the full reqwest crate, so you can use any feature it supports: multipart uploads, cookie stores, connection pooling, etc. Refer to the reqwest documentation for advanced usage.
A complete working example
Putting everything together, here is a React component that fetches data from a public API and handles loading, success, and error states:
// src/Posts.tsx
import { fetch } from '@tauri-apps/plugin-http';
import { useState, useEffect } from 'react';
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
function Posts() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts'
);
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
const data: Post[] = await response.json();
setPosts(data.slice(0, 5)); // just the first 5 posts
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
load();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export default Posts;
And the corresponding capability configuration that allows requests to jsonplaceholder.typicode.com:
// src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the main window",
"windows": ["main"],
"permissions": [
{
"identifier": "http:default",
"allow": [{ "url": "https://jsonplaceholder.typicode.com/**" }]
}
]
}
Verify the scope match:
If your fetch call throws "url not allowed on the scope", open the capability file and confirm that the exact URL you are using (including protocol and path) matches one of your allow patterns. A common mistake is to use http in the code and https in the scope (or vice versa).
Summary
Making HTTP requests in Tauri v2 means importing fetch from @tauri-apps/plugin-http and sending requests that route through the Rust backend. The API deliberately mirrors the browser’s fetch, but the underlying networking is entirely different — no CORS, no browser sandbox, and a mandatory allowlist that you define in the capability file.
The most critical step before any request works is configuring the URL scope. Without an explicit allow entry for your target domain, the plugin will reject the request before a single packet leaves the machine. Once the scope is set, you can use GET, POST, PUT, DELETE, and any other HTTP method with full control over headers, timeouts, redirects, and proxies.