Hosted ondailyplanet.iovia theHypermedia Protocol

The Case Against electron-trpc: When Type Safety Becomes a Performance TaxThe danger of making critical decisions without data and prioritizing developer convenience over user experience.

    Problem

      When we first integrated electron-trpc into our Electron desktop application, the promise was compelling: end-to-end type safety, a single source of truth for our API layer, and the developer experience benefits of autocomplete and automatic refactoring. What we didn't anticipate was the performance tax we'd be paying at runtime—a cost our users bear every time they launch the application.

      The reality is clear. Our current implementation forces every data access through a seven-layer abstraction stack: from UI components through the tRPC client, into the ipcLink adapter, across Electron's IPC boundary, through the main process IPC handler, into the router, and finally to the procedure implementation. Compare this to standard Electron IPC, which requires only three layers: component to contextBridge API to IPC handler. We're paying a 133% complexity tax for type safety that exists only during development.

      The numbers tell a sobering story. I analyzed our codebase and found 109 tRPC procedures across 26 modules, totaling 3,615 lines of code—roughly 40% of our entire desktop application codebase. Of these 109 procedures, 42 are simple getters and setters: operations like getSetting(key) or setFavorite(id, value). These trivial operations gain nothing from tRPC's sophisticated machinery, yet they still pay the full cost of serialization, deserialization, router traversal, and type inference at runtime.

      The bundle size impact is measurable: 28KB of additional JavaScript (tRPC client, SuperJSON transformer, and electron-trpc itself) that gets parsed and evaluated during startup. But the real performance killer isn't the bundle size—it's the runtime overhead. Every single call requires SuperJSON to serialize the request, send it across IPC, deserialize on the main process side, traverse the router tree, execute the procedure, serialize the response, send it back across IPC, and deserialize again. For a simple getter that returns a boolean, this is like using a shotgun to kill a fly.

      1

      Perhaps most troubling is what we discovered about our startup sequence. Because all 26 tRPC modules are eagerly loaded at application start, we're forcing the JavaScript engine to parse and evaluate thousands of lines of API code even for routes the user may never visit. There's no code splitting, no lazy loading, no optimization for the critical path. Every user pays the full cost of our entire API surface, regardless of which features they actually use.

    Solution

      The path forward requires surgical precision, not wholesale replacement. We don't need to rip out electron-trpc entirely—we need to recognize where it adds value and where it extracts a performance cost that exceeds its benefits.

      For the 39% of our procedures that are simple getters and setters, standard Electron IPC is the clear winner. A function like getSetting(key: string): string doesn't need Zod validation, doesn't benefit from complex type inference, and certainly doesn't warrant the overhead of SuperJSON transformation. These should be implemented as direct IPC calls exposed through contextBridge, giving us type safety at the interface boundary without the runtime overhead. We can still maintain TypeScript types for these APIs—they just won't be automatically derived from tRPC routers.

      For complex operations—draft management with its file I/O and migration logic, web content importing with its multi-step transformations, or any procedure with sophisticated input validation—electron-trpc remains valuable. These operations already have significant execution time measured in hundreds of milliseconds, so the additional IPC overhead is noise. The type safety and validation genuinely prevent bugs in these complex code paths.

      The second critical change is implementing proper code splitting. Our tRPC routers should be lazy-loaded by feature area. When a user opens the settings window, that's when we load the settings router. When they access drafts, that's when the drafts API becomes available. This requires restructuring our router tree to support dynamic imports and deferring the createIPCHandler call until routers are actually needed. It's more complex than our current eager-loading approach, but it's the difference between a 2-second startup and a 5-second startup.

      1

      Finally, we need to stop blocking the window creation on our full initialization sequence. The current pattern—spawn daemon, wait for it to become ready (up to 10 seconds), initialize drafts with file I/O, then finally show the window—is user-hostile. Instead, we should show the window immediately with a proper loading state, then initialize the daemon and drafts in parallel while providing real-time progress updates. This isn't strictly an electron-trpc issue, but it's symptomatic of the same mindset: prioritizing developer convenience over user experience.

      1

    Rabbit Holes

      The most dangerous rabbit hole when migrating away from electron-trpc is the temptation to build your own type-safe IPC abstraction. You'll be tempted to think:

      "we can keep the type safety without the overhead by building a lighter-weight version." Don't.

      The complexity of maintaining type synchronization between main and renderer processes, handling serialization edge cases, managing subscriptions, and keeping everything working as your API evolves will consume weeks of engineering time for marginal gains. If you're moving away from electron-trpc, move toward simplicity, not toward building electron-trpc-lite.

      Another trap is trying to maintain 100% feature parity during migration. You'll find yourself recreating tRPC's subscription mechanism, its batching capabilities, its middleware system. Each recreation adds back the complexity you're trying to remove. Instead, ask whether you actually use these features. In our codebase, we use subscriptions in exactly one place for query invalidation—we could replace that with a simple event emitter in five minutes. Batching? We're not making multiple related calls that would benefit from it. Middleware? We don't have any. Don't rebuild features you're not using.

      The third rabbit hole is perfectionism around migration strategy. You'll want to create detailed plans for migrating all 109 procedures, establishing patterns, writing comprehensive tests for the new IPC layer. This planning phase will expand to fill weeks while your users continue to experience slow startup times. Instead, pick the lowest-hanging fruit—those 42 simple getters and setters—and migrate them in a single focused session. Prove the pattern works, measure the impact, then decide whether to continue. Progress beats perfection.

    Scope

      The scope of this effort is deliberately constrained. We're not removing electron-trpc entirely, we're not rewriting the entire IPC layer, and we're not solving every performance issue in the application. We're making three targeted changes that address the most egregious performance problems with the highest return on investment.

      First, migrate simple getters and setters (the 42 identified procedures) to direct IPC. This includes settings, favorites, recents, and similar single-value operations. These should be completed in a single pull request to avoid having two patterns for simple operations living in the codebase simultaneously. The pattern should be consistent: contextBridge exposes a typed API object, ipcMain handlers implement the logic, and components use the exposed API directly. No tRPC, no SuperJSON, no router traversal.

      Second, implement lazy loading for tRPC routers that remain. This means splitting the monolithic router into feature-based modules that can be dynamically imported. The drafts router loads when the drafts feature is accessed. The web importing router loads when import functionality is needed. The settings router can stay eagerly loaded since it's needed for initial app configuration. This will require modifying how we create the IPC handlers and potentially introducing a router registry pattern, but it's a one-time infrastructure change that benefits all routers.

      Third, improve the startup sequence to handle daemon initialization gracefully. Work is already underway in the feat/app-startup branch to check daemon state and show a dedicated loading window when the daemon is migrating, rather than blocking the main window creation. This approach gives users immediate visual feedback and prevents the blank screen experience during daemon startup. While not strictly related to electron-trpc, it's the most visible performance problem users experience, and completing this work removes the artificial coupling between daemon readiness and window visibility.

      What's explicitly out of scope: rewriting complex operations like draft management or web importing. These procedures have sophisticated logic and benefit from tRPC's type safety. Also out of scope: building abstractions to make the migration "cleaner" or trying to maintain identical APIs between the old and new approaches. Different paradigms can have different interfaces—that's fine.

    No-Gos

      Do not attempt to build a custom type-safe IPC framework as part of this effort. The goal is to reduce complexity, not to replace one abstraction with another that we have to maintain. If you find yourself writing code that generates types from schemas or automatically syncs interfaces between processes, stop immediately. You're rebuilding tRPC and you will regret it.

      Do not migrate complex procedures away from electron-trpc just for consistency. If a procedure has multiple input parameters, complex validation logic, or sophisticated error handling, it's probably fine to keep it in tRPC. The performance overhead is only problematic when it's disproportionate to the operation's inherent cost. A draft creation operation that does file I/O, validation, and database writes can afford an extra 2 milliseconds of IPC overhead. A boolean getter cannot.

      Do not introduce new dependencies to solve these problems. There will be temptation to bring in libraries for IPC message validation, for type generation, for performance monitoring. Each dependency is a future maintenance burden and a potential attack surface. Electron's built-in IPC with TypeScript interfaces is sufficient for our needs in this particular simple usecases. If it feels too manual compared to tRPC, that's because you're doing less work at runtime—which is the entire point.

      Do not skip measuring the impact of changes. Before migrating procedures, establish baseline metrics: startup time, time to first render, bundle size. After migration, measure again. If you're not seeing measurable improvements, you may be optimizing the wrong thing. Performance work without measurement is just guessing, and guessing wastes time that could be spent on features users actually want.

      Finally, do not let this migration become a referendum on electron-trpc as a technology. It's a well-built library that solves real problems for many applications. It's not the right fit for our application given our performance requirements and our API patterns. That's a specific conclusion about specific circumstances, not a universal truth. When you're done with this migration, electron-trpc will still be a good choice for applications where the DX benefits outweigh the runtime costs—applications with complex APIs, web-based backends that need IPC adapters, or tools where load time isn't critical. We're just not one of those applications.