RabbitGUI is a desktop client for RabbitMQ that runs on macOS, Windows and Linux. It is built with Electron, and this article explains how the pieces fit together. The main process talks to RabbitMQ, exposes everything to the UI through a typed tRPC API, and stores messages in a local SQLite database. The renderer is a plain React app that never touches the network or the disk itself.
If you are building a developer tool on Electron, most of this applies to you too. None of it is specific to RabbitMQ.
RabbitMQ already ships a web UI, the management plugin. We could have built another web app on top of the same HTTP API. We didn't, for three reasons:
localStorage.Electron gives us all three while keeping a single language, TypeScript, from the AMQP socket down to the React components.
┌──────────────────────────── Electron app ────────────────────────────┐
│ │
│ Renderer process (Chromium) Main process (Node.js) │
│ ───────────────────────── ────────────────────── │
│ React 19 tRPC router │
│ TanStack Router + Query ── IPC ──▶ ├─ fetch ──▶ Management API │
│ tRPC client (ipcLink) ◀───────── ├─ amqplib ─▶ AMQP 0-9-1 │
│ Monaco, React Flow, └─ Kysely + better-sqlite3 │
│ Recharts, Tailwind │ │
│ ▼ │
│ user-data.db (SQLite, WAL) │
└──────────────────────────────────────────────────────────────────────┘
│
▼
Your RabbitMQ broker(s)The rule is simple: the renderer only renders. Anything that touches the network, the file system or a secret lives in the main process, and the renderer reaches it only through typed tRPC procedures.
Most of what you see in RabbitGUI comes from the RabbitMQ management API: queues, exchanges, bindings, policies, users, vhosts and the message rate time series. In the main process it is just fetch with basic auth:
export const api = async <T>(path: string, { method = 'get', credentials, body }) => {
const response = await fetch(credentials.baseUrl + path, {
method,
headers: {
Authorization:
'Basic ' +
Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'),
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
// Map RabbitMQ errors to typed tRPC errors (404 => NOT_FOUND, etc.)
}
return (await response.json()) as T
}Since the request comes from Node.js and not from a web page, there is no CORS to configure on the broker. You point RabbitGUI at the same URL you would open in your browser, and it works.
The API is paginated, so the main process walks every page before returning. The renderer always receives the full list and can sort and filter it instantly:
const data = await queues(credentials, { page: 1, pageSize: 100 })
while (data.page_count > data.page) {
const next = await queues(credentials, { page: data.page + 1, pageSize: 100 })
data.items.push(...next.items)
data.page = next.page
}The management API has a "get messages" endpoint, but the RabbitMQ docs describe it as a diagnostics tool for sysadmins: it does not implement reliable delivery and pulls messages one HTTP request at a time. For anything involving many messages we open a real AMQP connection with amqplib, still from the main process.
The live queue spy is a good example. To watch a queue without stealing messages from your consumers, RabbitGUI declares a temporary, exclusive queue and copies the bindings of the queue you are watching onto it. RabbitMQ then delivers a copy of each matching message to both queues:
const connection = await amqp.connect(amqpUrl)
const channel = await connection.createChannel()
// Server-named, exclusive, deleted when the connection closes
const { queue: tempQueue } = await channel.assertQueue('', {
exclusive: true,
autoDelete: true,
})
// Same bindings as the queue we are spying on
for (const binding of await queueBindings(credentials, { vhost, queue })) {
if (binding.source) {
await channel.bindQueue(tempQueue, binding.source, binding.routing_key, binding.arguments)
}
}
await channel.consume(tempQueue, async (msg) => {
if (!msg) return
channel.ack(msg)
await db.insertInto('consumed_messages').values(toRow(msg)).execute()
})Because the spy queue is exclusive and auto-delete, closing the app or the connection removes it. Nothing is left behind on your broker.
The same idea powers browsing a queue without consuming it. RabbitGUI consumes the queue, writes every message to SQLite, and either requeues them or removes them, depending on the mode you pick. Requeued messages come back with the redelivered flag set, and on quorum queues with an incremented x-delivery-count. So we strip those headers and hash the body plus the remaining headers. If you extract the same queue twice, you don't get duplicates in the local database.
Electron's IPC is untyped. You ipcRenderer.invoke('some-channel', args) and hope the main process handles that string. With a few dozen features, that gets fragile fast. So we put tRPC on top of it with trpc-electron.
The main process defines ordinary tRPC routers, validated with zod:
export const queuesRouter = router({
list: publicProcedure.input(z.string()).query(async ({ input: connexionId }) => {
// management API + local SQLite counts
}),
purge: publicProcedure
.input(z.object({ connexionId: z.string(), vhost: z.string(), queue: z.string() }))
.mutation(async ({ input }) => {
await purgeQueue(getCredentials(input.connexionId), { name: input.queue, vhost: input.vhost })
}),
})
export const appRouter = router({
connexions: connexionsRouter,
queues: queuesRouter,
exchanges: exchangesRouter,
localMessages: localMessagesRouter,
spy: spyRouter,
// ...
})
export type AppRouter = typeof appRouterIt is wired to the window with a single call:
createIPCHandler({ router: appRouter, windows: [mainWindow] })The preload script exposes the bridge (exposeElectronTRPC()), and the renderer creates a client with an IPC link instead of an HTTP one:
export const trpcClient = createTRPCClient<AppRouter>({
links: [ipcLink()],
})
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
})What we get out of this:
AppRouter type. Renaming a procedure or changing an input breaks the renderer build, not the app at runtime.useQuery(trpc.queues.list.queryOptions(connexionId)), with caching, refetch intervals, loading states and invalidation after mutations. Live dashboards are just a refetchInterval.TRPCErrors. The renderer can, for example, skip retries on a NOT_FOUND when a queue was deleted under your feet.ipcMain.handle for app features.Long-running jobs, like extracting 50,000 messages, don't block a request. The mutation starts the job in the main process and returns. The renderer polls a status query that reports progress until the job is done.
Messages you extract, spy on or publish go into a SQLite database in Electron's userData folder. We use better-sqlite3 with Kysely as a type-safe query builder:
const sqlite = new SQLite(path.join(app.getPath('userData'), 'user-data.db'))
sqlite.pragma('journal_mode = WAL')
export const db = new Kysely<DB>({
dialect: new SqliteDialect({ database: sqlite }),
})Schema changes ship as Kysely migrations defined in code and run at startup with migrator.migrateToLatest(), so a user who skips a few versions still ends up with the right schema. In development, kysely-codegen regenerates the DB type from the real database after each migration. Queries stay typed without maintaining types by hand.
The main table, consumed_messages, stores the payload, the AMQP properties, and a few columns lifted out of the headers for fast filtering: delivery count, and the first and last death exchange, queue and reason from x-death. Composite indexes on (connexionId, vhost, queue, …) keep filters and facet counts fast on large extractions.
Having the messages in SQL is what makes the dead-letter workflow possible:
The spy view keeps the newest 1,000 messages per queue and deletes older rows, so a busy queue can't fill your disk.
Small settings, like connections, saved table views and appearance, live in a JSON config file managed by electron-conf and not in SQLite.
Because everything heavy happens in the main process, the renderer is a regular single-page React app:
There is no API base URL to configure, no auth token to refresh, no CORS. The renderer could run in a browser tab against a different link without code changes, which also makes it easy to iterate on with hot reload through electron-vite.
electronuserland/builder Docker image.electron-builder install-app-deps runs on postinstall to take care of that. It is the one part of the stack that occasionally needs attention after an Electron upgrade.Electron isn't free. The download is bigger than a native app's, and an idle window uses more memory than a terminal tool would. For a tool you open to debug a broker, we think it's the right trade:
Wiring all of this by hand takes days: the IPC bridge, native module rebuilds, migrations, code signing, notarization and auto updates. If you want the same architecture for your own desktop app, start from a boilerplate instead.
We extracted the stack behind RabbitGUI into Electron AppStack, a free, production-ready Electron + TypeScript starter. It ships with React, tRPC over IPC, SQLite, auto updates and signed releases, plus documentation for each piece:
npx degit nick-keller/electron-appstack/template my-app
cd my-app
npm install
npm run devYou get the main process / renderer split described in this article on day one, and can spend your time on the features that make your app different.
If you are curious what this architecture adds up to in practice, RabbitGUI is available for macOS, Windows and Linux, or have a look at what it does compared to the management UI.
See how RabbitGUI can make your everyday RabbitMQ workflows easier.
Available on Windows, Mac, and Linux.

ProductWe built our own license server. Here's why we're ditching itWe built our own license management backend for RabbitGUI. Here is why we are now moving to LicenseManager.dev, and what it changes for you.
ProductHow to browse RabbitMQ messages without consuming themRabbitMQ has no read-only API to browse a queue. Learn what the management UI's "Get messages" actually does, the side effects of requeueing, and how to inspect messages safely with RabbitGUI.
ProductHow to spy on real-time queue traffic in RabbitMQ?Inspecting live messages flowing through a RabbitMQ queue is tricky because consuming is destructive. Learn how RabbitGUI creates a shadow queue to capture traffic without affecting your application.
ProductHow to predict when a RabbitMQ queue will be empty?A step-by-step explanation of how to estimate backlog drain time for a RabbitMQ queue, from naive division to linear regression with adaptive windowing.
ProductAnnouncing RabbitGUI 1.1: Now on Windows and LinuxRabbitGUI v1.1 brings native support for Windows, Linux, and Intel-based Macs, along with a built-in auto updater. Here's why this release matters.
ProductHow to log into your CloudAMQP RabbitMQ instanceUse RabbitGUI to connect to your CloudAMQP instance and manage your dead letter queues with ease