Why I built my own API client in Electron instead of using Postman
A look into why I ditched Postman for a custom, local-first API client built with Electron and Vue, tackling CORS blocks and filesystem persistence.
I used to open Postman fifty times a day. It was a utility tool that sat quietly in the background, executing HTTP requests and staying out of the way. Over the last couple of years, however, that experience degraded completely into an enterprise cloud platform complete with forced user accounts, heavy telemetry, and constant prompts to sync my scratchpads to a remote workspace.
When they began deprecating the offline scratchpad functionality entirely, I closed the app and deleted it. I didn't want a team collaboration workspace; I wanted an open window to test local API endpoints. Instead of moving to another commercial alternative that would likely follow the same trajectory, I spent two weeks building a lightweight, local-first API client tailored exactly to my workflow. The project became Relay.
Choosing to build a desktop tool means committing to specific structural constraints. You cannot simply build a standard web application and wrap it in a shell; you have to bridge the web environment with native desktop capabilities without adding unnecessary runtime bloat.
Why Electron over Tauri for this specific build
When you mention building a desktop app with web technologies today, the immediate response is always: Why didn't you use Tauri? Tauri is great. It yields tiny application binaries because it links directly into the operating system's native Webview engine, whereas Electron ships with an entire instance of Chromium and Node.js bundled inside every package.
For Relay, however, Electron was a deliberate architectural decision based on network control. In Tauri, your frontend code runs inside the system webview (like WebKit on macOS or WebView2 on Windows). These webviews strictly enforce standard browser-level security policies, including Cross-Origin Resource Sharing (CORS).
If you try to dispatch a fetch request from a local browser context directly to a remote production API or a local microservice running on port 8080 without explicit CORS headers, the runtime engine will block the request immediately.
// Standard browser requests fail without CORS headers
async function testEndpoint() {
try {
// This throws an uncatchable CORS error if the destination server doesn't allow it
await fetch('https://api.thirdparty.com/v1/data', { method: 'POST' });
} catch (err) {
console.error('CORS blocked this request.');
}
}
Electron solves this elegantly through its architectural split. It runs a powerful Main process (Node.js) completely decoupled from the visible Renderer process (Chromium). By dispatching network requests through Node's native net or axios module inside the Main process, the request originates from a system-level runtime, not a browser window. It bypasses CORS restrictions entirely, allowing you to hit any endpoint on the web exactly like curl would.
Bypassing CORS blocks via IPC routing
To make this network bypass function smoothly, the user interface running in the Vue renderer must communicate with the backend Node process using Inter-Process Communication (IPC) channels.
When you click "Send Request" in the UI layer, the application stringifies the request configuration (headers, payload, URL, HTTP method) and pushes it across the IPC bridge.
// preload.ts — exposing the secure bridge to the renderer
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('apiBridge', {
sendHttpRequest: (config: RequestConfig) => ipcRenderer.invoke('execute-http-request', config)
});
On the receiving side inside the Main process, a dedicated controller interceptor registers the action, fires the request using a clean Axios instance running inside the Node context, collects the response payload, and passes it back over the bridge to the Vue view.
// main.ts — handling the execution inside the Node runtime
import { ipcMain } from 'electron';
import axios from 'axios';
ipcMain.handle('execute-http-request', async (event, config) => {
try {
const response = await axios({
url: config.url,
method: config.method,
headers: config.headers,
data: config.data,
validateStatus: () => true // Prevent axios from throwing on 4xx/5xx responses
});
return {
status: response.status,
headers: response.headers,
data: response.data
};
} catch (error: any) {
return { status: 0, headers: {}, data: error.message };
}
});
This structural separation means the user interface remains completely fluid. Large JSON payloads are parsed safely inside the background thread, and the renderer never freezes because it is never tasked with managing raw TCP socket wrappers directly.
Pinia and filesystem persistence
An API client is useless if it loses your workspace configuration, request histories, or collection folders the moment you quit the app. Because Relay is a local-first application, I wanted all data saved as human-readable JSON files directly inside the user's local application directory.
For global state management across the UI components, I utilised Pinia. To tie the state schema directly to the machine's local disk, I wrote a custom persistence plugin that interfaces with Electron's exposed filesystem primitives.
// A simplified look at local filesystem state sync
import { watch } from 'vue';
import { defineStore } from 'pinia';
export const useCollectionStore = defineStore('collections', () => {
const collections = ref<Collection[]>([]);
// Watch for inner mutations and stream them straight to the local drive
watch(collections, (newCollections) => {
window.apiBridge.saveToDisk('collections.json', JSON.stringify(newCollections, null, 2));
}, { deep: true });
return { collections };
});
Every time you modify a header value, add a query parameter, or create a new collection folder, Pinia catches the mutation and passes the payload over the bridge to the main process, which commits the changes to disk using atomic filesystem operations.
If you migrate to a new laptop, you don't need to export workspaces from a cloud dashboard; you just copy the JSON files out of your application support directory and drop them into the new machine.
Trade-offs and what doesn't work
While the architecture works perfectly for my workflow, Electron apps carry an undeniable cost. The baseline application package takes up nearly 90MB of disk space for an interface that is essentially an advanced HTTP wrapper. Memory footprint sits consistently around 110MB when idle because it runs a full Chromium rendering pipeline behind the scenes.
Furthermore, shipping complex objects over the IPC bridge introduces an allocation penalty. When you pull down a massive 20MB JSON log file from an API endpoint, that dataset has to be fully stringified, passed across the IPC channel, and parsed a second time into the JavaScript heap of the renderer process. If you try to run high-volume stress tests or log millions of consecutive API calls, this serialisation boundary will degrade your system performance quickly.
Closing take
Building a custom tool sounds like a distraction, but removing the bloat of commercial developer tools changes the daily feedback loop. Relay does not require a connection handshake, does not collect analytics metrics, and loads instantly. If you want to see how the interface layout came together or inspect the design choices for managing complex workspace collections, take a look at the project overview for Relay.