JSON in Electron: IPC, electron-store, and File System JSON

Last updated:

Electron passes JSON between the main process and renderer process using IPC (Inter-Process Communication): ipcMain.handle('get-data', handler) on the main side and ipcRenderer.invoke('get-data') on the renderer side. IPC arguments and return values are serialized with the Structured Clone Algorithm — which supports Date, Map, Set, ArrayBuffer, and circular references beyond what JSON.stringify handles. For persistent JSON storage, electron-store provides a typed key-value store backed by a JSON file in app.getPath('userData') — it handles file creation, atomic writes, and schema validation via Ajv. For reading and writing arbitrary JSON files, use Node.js fs.promises in the main process and expose the operation to the renderer via contextBridge. With contextBridge.exposeInMainWorld('api', { readConfig: () => ipcRenderer.invoke('read-config') }), the renderer gets a safe, typed API without direct Node.js access. This guide covers main-to-renderer IPC JSON patterns, electron-store config persistence, contextBridge typed APIs, JSON file read/write from main, and TypeScript types end-to-end.

IPC JSON: Main Renderer Communication

Bottom line: ipcMain.handle('channel', async (event, ...args) => result) registers a handler on the main process that returns JSON. ipcRenderer.invoke('channel', ...args) calls it from the renderer and returns a Promise.

Arguments and return values go through the Structured Clone Algorithm — not JSON.stringify. Structured Clone supports values that fail JSON.stringify: Date objects stay as Date, undefined is preserved, Infinity and NaN are preserved. It does not support functions or class instances with methods. For one-way fire-and-forget messaging from renderer to main, use ipcMain.on and ipcRenderer.send — these have no return value. The handle/invoke pair handles 3 scenarios: fetching data from a database, saving a config, and opening a file dialog.

// main.ts — register handlers before app 'ready' event
import { app, BrowserWindow, ipcMain } from 'electron'
import { db } from './db'

// Async request-response: returns JSON to the renderer
ipcMain.handle('get-products', async () => {
  const products = await db.getAll()  // returns Product[]
  return products                     // Structured Clone sends it to renderer
})

ipcMain.handle('save-config', async (event, config: AppConfig) => {
  await db.saveConfig(config)
  return { ok: true }
})

// One-way from renderer to main (no return value)
ipcMain.on('log', (event, msg: { level: string; msg: string }) => {
  console.log(`[${msg.level}] ${msg.msg}`)
})
// preload.ts — expose IPC to renderer via contextBridge
import { contextBridge, ipcRenderer } from 'electron'
import type { Product, AppConfig } from '../shared/types'

contextBridge.exposeInMainWorld('api', {
  getProducts: (): Promise<Product[]> =>
    ipcRenderer.invoke('get-products'),
  saveConfig: (config: AppConfig): Promise<{ ok: boolean }> =>
    ipcRenderer.invoke('save-config', config),
  log: (msg: { level: string; msg: string }): void =>
    ipcRenderer.send('log', msg),
})
// renderer.ts — call window.api (typed via .d.ts)
const products = await window.api.getProducts()
// products is Product[] — fully typed, no JSON.parse needed

await window.api.saveConfig({ theme: 'dark', fontSize: 14 })

window.api.log({ level: 'info', msg: 'renderer ready' })

// Structured Clone preserves types JSON.stringify loses:
// If the main handler returns new Date(), the renderer receives
// a real Date object — not a string like JSON.stringify produces.

electron-store for JSON Config Persistence

Bottom line: electron-store is the standard solution for persisting JSON config in Electron apps — it handles file creation, atomic writes, and Ajv schema validation with a 5-method API.

Install with npm install electron-store. The store file lives at app.getPath('userData')/config.json by default. Pass a name option to change the filename. The defaults option sets initial values; store.get(key) falls back to the default if the key is missing. For TypeScript, the generic Store<T> makes every store.get(key) call return the type declared in T — no casting needed. Schema validation uses Ajv: pass a schema object matching JSON Schema Draft-07, and every store.set call is validated before writing. There are 4 core operations: get, set, delete, and clear.

import Store from 'electron-store'

// TypeScript interface for config shape
interface AppConfig {
  theme: 'dark' | 'light'
  fontSize: number
  recentFiles: string[]
}

// Typed store with defaults and Ajv schema validation
const store = new Store<AppConfig>({
  defaults: {
    theme: 'dark',
    fontSize: 14,
    recentFiles: [],
  },
  schema: {
    theme: {
      type: 'string',
      enum: ['dark', 'light'],
    },
    fontSize: {
      type: 'number',
      minimum: 8,
      maximum: 32,
    },
    recentFiles: {
      type: 'array',
      items: { type: 'string' },
      maxItems: 10,
    },
  },
})
// Read — returns 'dark' | 'light' (typed from AppConfig)
const theme = store.get('theme')     // → 'dark'
const size  = store.get('fontSize')  // → 14 (number, not unknown)

// Write — validated by Ajv against schema
store.set('theme', 'light')       // ok
store.set('fontSize', 16)         // ok
// store.set('fontSize', 100)     // throws: 100 > maximum 32

// Delete a key (reverts to default on next get)
store.delete('fontSize')

// Clear all keys
store.clear()

// Get the entire config as a plain object
const allConfig = store.store    // → AppConfig

// Get the JSON file path
console.log(store.path)
// macOS: /Users/alice/Library/Application Support/MyApp/config.json
// Windows: C:\Users\alice\AppData\Roaming\MyApp\config.json
// Linux: /home/alice/.config/MyApp/config.json

contextBridge: Safe JSON APIs for the Renderer

Bottom line: in context-isolated Electron apps (contextIsolation: true, the default since Electron 12), contextBridge.exposeInMainWorld is the only correct way to give the renderer access to IPC or Node.js APIs.

Without context isolation, the renderer and preload share the same JavaScript context — meaning any compromised third-party script could access ipcRenderer directly and call arbitrary IPC channels. With contextIsolation: true, the preload runs in an isolated context and only what you explicitly expose via contextBridge is visible to the renderer. All values that cross the bridge are deep-cloned — only JSON-compatible values, Promises, and proxied functions can pass through. To get TypeScript autocompletion for window.api in the renderer, declare the type in a .d.ts file with 3 required elements: the global declaration, the Window interface augmentation, and the method signatures.

// preload.ts
import { contextBridge, ipcRenderer } from 'electron'
import type { Product, AppConfig } from '../shared/types'

contextBridge.exposeInMainWorld('api', {
  // Returns a Promise — contextBridge proxies it correctly
  getProducts: (): Promise<Product[]> =>
    ipcRenderer.invoke('get-products'),

  saveConfig: (config: AppConfig): Promise<void> =>
    ipcRenderer.invoke('save-config', config),

  // Renderer-to-main event (no return value)
  onThemeChange: (callback: (theme: string) => void) =>
    ipcRenderer.on('theme-changed', (_event, theme) => callback(theme)),
})
// src/renderer.d.ts — global type declaration for window.api
import type { Product, AppConfig } from '../shared/types'

declare global {
  interface Window {
    api: {
      getProducts: () => Promise<Product[]>
      saveConfig: (config: AppConfig) => Promise<void>
      onThemeChange: (callback: (theme: string) => void) => void
    }
  }
}

export {}
// main.ts — BrowserWindow config
new BrowserWindow({
  webPreferences: {
    preload: path.join(__dirname, 'preload.js'),
    contextIsolation: true,   // default since Electron 12
    nodeIntegration: false,   // keep Node.js out of renderer
  },
})

JSON File Read/Write from Main Process

Bottom line: read and write JSON files in the main process using fs.promises, exposed to the renderer via IPC handlers. Never read/write files directly from the renderer — use the main process as the gatekeeper.

The main process runs in Node.js, so fs.promises.readFile and fs.promises.writeFile are available without any extra setup. Register IPC handlers that wrap these operations and return JSON to the renderer. For user data files, always resolve paths relative to app.getPath('userData') — never accept arbitrary absolute paths from the renderer to prevent path traversal vulnerabilities. For crash safety on writes, use the atomic write pattern: write to a .tmp file first, then fs.promises.rename to replace the target — on POSIX systems, rename is atomic. For user-selected files, use dialog.showOpenDialog which returns paths only after explicit user approval — 3 key options are properties, filters, and defaultPath.

import { app, ipcMain, dialog } from 'electron'
import fs from 'fs'
import path from 'path'

const userDataPath = app.getPath('userData')

// Read a named JSON file from userData
ipcMain.handle('read-json', async (event, filename: string) => {
  const filePath = path.join(userDataPath, filename)
  const content = await fs.promises.readFile(filePath, 'utf-8')
  return JSON.parse(content)
})

// Atomic write: temp file → rename
ipcMain.handle('write-json', async (event, filename: string, data: unknown) => {
  const filePath = path.join(userDataPath, filename)
  const tmpPath  = filePath + '.tmp'
  await fs.promises.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8')
  await fs.promises.rename(tmpPath, filePath)  // atomic on POSIX
})

// Let the user pick a JSON file with the native dialog
ipcMain.handle('open-json-dialog', async () => {
  const result = await dialog.showOpenDialog({
    properties: ['openFile'],
    filters: [{ name: 'JSON Files', extensions: ['json'] }],
    defaultPath: app.getPath('documents'),
  })
  if (result.canceled || result.filePaths.length === 0) return null
  const content = await fs.promises.readFile(result.filePaths[0], 'utf-8')
  return JSON.parse(content)
})
// preload.ts — expose to renderer
contextBridge.exposeInMainWorld('api', {
  readJson:       (filename: string)             => ipcRenderer.invoke('read-json', filename),
  writeJson:      (filename: string, data: unknown) => ipcRenderer.invoke('write-json', filename, data),
  openJsonDialog: ()                             => ipcRenderer.invoke('open-json-dialog'),
})

// renderer.ts — use the safe API
const settings = await window.api.readJson('settings.json')
await window.api.writeJson('settings.json', { theme: 'dark', fontSize: 16 })

const imported = await window.api.openJsonDialog()  // null if cancelled
if (imported) console.log('Imported:', imported)

TypeScript Types End-to-End

Bottom line: define shared interfaces in shared/types.ts, use Store<AppConfig> for typed electron-store, and augment Window in a .d.ts file for typed renderer API access.

End-to-end TypeScript coverage requires 4 pieces: shared type definitions, typed IPC handlers, a typed preload API, and a typed Window augmentation. The Store<T> generic makes every store.get(key) call return the exact type from T — no casts. The ipcRenderer.invoke<T>(channel) generic passes through to Promise<T> in the renderer. The electron-vite build tool is the recommended setup for TypeScript Electron apps — it compiles main, preload, and renderer with separate tsconfig.json files, supports path aliases, and provides hot module replacement for the renderer during development. A typical project has 3 TypeScript compilation targets and 3 distinct output directories.

// shared/types.ts — imported by main, preload, and renderer
export interface Product {
  id: number
  name: string
  price: number
  tags: string[]
}

export interface AppConfig {
  theme: 'dark' | 'light'
  fontSize: number
  recentFiles: string[]
  windowBounds: {
    x: number
    y: number
    width: number
    height: number
  }
}
// main.ts — typed IPC handlers
import Store from 'electron-store'
import type { AppConfig, Product } from '../shared/types'

// electron-store generic: store.get returns AppConfig field types
const store = new Store<AppConfig>({
  defaults: { theme: 'dark', fontSize: 14, recentFiles: [], windowBounds: { x: 0, y: 0, width: 900, height: 600 } },
})

ipcMain.handle('get-config', (): AppConfig => store.store)
ipcMain.handle('set-config', (event, patch: Partial<AppConfig>) => {
  Object.entries(patch).forEach(([k, v]) => store.set(k as keyof AppConfig, v))
})

// Return type annotation ensures the handler returns Product[]
ipcMain.handle('get-products', async (): Promise<Product[]> => {
  return db.getAllProducts()
})
// preload.ts — typed contextBridge API
import { contextBridge, ipcRenderer } from 'electron'
import type { Product, AppConfig } from '../shared/types'

contextBridge.exposeInMainWorld('api', {
  getConfig:    (): Promise<AppConfig>             => ipcRenderer.invoke('get-config'),
  setConfig:    (patch: Partial<AppConfig>): Promise<void> => ipcRenderer.invoke('set-config', patch),
  getProducts:  (): Promise<Product[]>             => ipcRenderer.invoke('get-products'),
})

// src/renderer.d.ts
declare global {
  interface Window {
    api: {
      getConfig:   () => Promise<AppConfig>
      setConfig:   (patch: Partial<AppConfig>) => Promise<void>
      getProducts: () => Promise<Product[]>
    }
  }
}
export {}

JSON in the Electron App Lifecycle

Bottom line: use app.getPath('userData') for all user data JSON and app.isPackaged to switch between development and production JSON paths.

app.getPath('userData') is OS-specific and scoped to your app name: on macOS it resolves to ~/Library/Application Support/AppName, on Windows to %APPDATA%\AppName, and on Linux to ~/.config/AppName. Use app.getPath('logs') for structured log JSON files — Electron creates this directory automatically. Read the app version from package.json at runtime: path.join(app.getAppPath(), 'package.json') — more reliable than hardcoding. Persist window bounds across sessions by saving { x, y, width, height } to electron-store on close and restoring it on open — this is a common pattern that requires 3 event listeners: resize, move, and close.

import { app, BrowserWindow } from 'electron'
import Store from 'electron-store'
import path from 'path'
import fs from 'fs'

// OS-specific paths
console.log(app.getPath('userData'))
// macOS:   /Users/alice/Library/Application Support/MyApp
// Windows: C:\Users\alice\AppData\Roaming\MyApp
// Linux:   /home/alice/.config/MyApp

// Read app version from package.json (works in packaged app too)
const pkgPath = path.join(app.getAppPath(), 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
console.log('App version:', pkg.version)   // → '1.4.2'

// app.isPackaged: true in production, false in dev
const dataDir = app.isPackaged
  ? app.getPath('userData')
  : path.join(app.getAppPath(), 'dev-data')
// Persist and restore window bounds
interface WindowBounds { x: number; y: number; width: number; height: number }
const store = new Store<{ windowBounds: WindowBounds }>({
  defaults: { windowBounds: { x: 0, y: 0, width: 900, height: 600 } },
})

function createWindow() {
  const bounds = store.get('windowBounds')

  const win = new BrowserWindow({
    ...bounds,
    webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true },
  })

  // Save bounds on resize and move
  const saveBounds = () => store.set('windowBounds', win.getBounds())
  win.on('resize', saveBounds)
  win.on('move',   saveBounds)

  // Save final bounds before close
  win.on('close', saveBounds)
  return win
}

app.whenReady().then(createWindow)

FAQ

How do I pass JSON from main to renderer in Electron?

Use ipcMain.handle on the main process and ipcRenderer.invoke on the renderer. ipcMain.handle('channel', async () => data) returns JSON serialized with Structured Clone — not JSON.stringify — so Date, Map, Set, and ArrayBuffer are preserved. ipcRenderer.invoke('channel') returns a Promise. Always wrap ipcRenderer.invoke in a contextBridge preload function so the renderer never touches ipcRenderer directly. For one-way fire-and-forget events, use ipcMain.on and ipcRenderer.send.

How do I persist JSON config in an Electron app?

Use electron-store: npm install electron-store, then const store = new Store<AppConfig>({ defaults, schema }). Read with store.get('key'), write with store.set('key', value), delete with store.delete('key'), and get the file path from store.path. The TypeScript generic makes every get call return the exact type from AppConfig. The schema option validates every write with Ajv — pass an Ajv JSON Schema Draft-07 object to reject invalid values at the point of assignment.

What is contextBridge and why do I need it for JSON?

contextBridge is the Electron API that safely exposes functions from the preload script to the renderer when contextIsolation: true (default since Electron 12). Without it, the renderer cannot access Node.js or IPC APIs. Use contextBridge.exposeInMainWorld("api", { getProducts: () => ipcRenderer.invoke("get-products") }) in preload.ts. Every value crossing the bridge is deep-cloned — only JSON-compatible values, Promises, and proxied functions pass through. Declare the global type in renderer.d.ts with interface Window { api: ... } for renderer TypeScript autocompletion.

How do I read and write JSON files in Electron?

Use fs.promises.readFile and fs.promises.writeFile in the main process, wrapped in ipcMain.handle handlers. Store user data JSON under app.getPath('userData') — never accept arbitrary absolute paths from the renderer to prevent path traversal. For crash safety, write to a .tmp file first and then call fs.promises.rename — rename is atomic on POSIX. Use dialog.showOpenDialog to let users select a JSON file with the native file picker — it returns only paths the user explicitly approved.

How do I add TypeScript types to Electron IPC JSON?

Define shared interfaces in shared/types.ts imported by main, preload, and renderer. Use new Store<AppConfig>({ schema }) for typed electron-store. In preload, annotate each exposed function's return type explicitly: getProducts: (): Promise<Product[]> => ipcRenderer.invoke("get-products"). Declare interface Window { api: { getProducts: () => Promise<Product[]> } } in renderer.d.ts. Use electron-vite as your build tool — it compiles main, preload, and renderer with separate TypeScript configurations and handles path aliases correctly for all 3 targets.

Where should I store JSON data in an Electron app?

Store user config and data at app.getPath('userData') — the OS-specific path is ~/Library/Application Support/AppName on macOS, %APPDATA%\AppName on Windows, and ~/.config/AppName on Linux. Use app.getPath('logs') for log JSON files. Read app metadata from path.join(app.getAppPath(), 'package.json'). Use app.isPackaged to switch between dev and prod paths — electron-store handles this automatically. Never write to the app installation directory — it may be read-only and is overwritten on updates.

Definitions

IPC (Inter-Process Communication)
The mechanism Electron uses to pass messages and JSON data between the main process and renderer processes. Implemented via ipcMain and ipcRenderer modules.
contextBridge
An Electron API used in the preload script to safely expose functions to the renderer process when context isolation is enabled. Values passing through are deep-cloned.
electron-store
A third-party npm package that provides a simple typed key-value store backed by a JSON file in the OS-specific user data directory. Supports TypeScript generics and Ajv schema validation.
Structured Clone Algorithm
The serialization algorithm Electron uses for IPC messages. Unlike JSON.stringify, it supports Date, Map, Set, ArrayBuffer, and circular references. It does not support functions or class instances with methods.
contextIsolation
A BrowserWindow option (default true since Electron 12) that runs the preload script in a separate JavaScript context from the renderer, preventing renderer code from accessing Node.js APIs directly.
app.getPath('userData')
An Electron API that returns the OS-specific directory for storing persistent user data and config JSON files, scoped to your app name. The path differs per platform: Application Support on macOS, AppData/Roaming on Windows, .config on Linux.

Building with Tauri instead?

Tauri uses a different IPC model based on Rust commands. The JSON serialization patterns are similar but the APIs differ. See the JSON in Tauri guide for the equivalent patterns, or explore JSON config patterns and JSON in localStorage for web-focused storage approaches.

Open JSON Validator

Further reading and primary sources

  • Electron IPC documentationOfficial Electron docs for ipcMain.handle, ipcMain.on, and the full IPC API for the main process
  • contextBridge API referenceOfficial Electron documentation for contextBridge.exposeInMainWorld, value type restrictions, and context isolation security model
  • electron-store on npmelectron-store GitHub repository with full API documentation, TypeScript generics usage, Ajv schema validation, and migration examples
  • Electron security checklistOfficial Electron security guide covering context isolation, nodeIntegration, contextBridge usage, and IPC input validation best practices
  • electron-viteBuild tool for TypeScript Electron apps with separate tsconfig for main/preload/renderer, Vite HMR for the renderer, and path alias support