My Emacs printed this after starting:

Doom loaded 286 packages across 71 modules in 1.713s

Another machine running Arch, apparently less powerful, started Emacs in under a second. I wondered whether macOS was responsible.

I had no controlled comparison between the machines. Different builds, packages and caches could all matter. But I did have a configuration I could inspect, and it turned out to contain work I was asking Emacs to do before I needed it.

The startup investigation became part of a wider review of my Doom configuration. I keep its source in config.org; config.el is generated from it. Reading the prose beside each customization made the review more useful: I could compare what I said I wanted with what the installed code actually did.

The checks described below used Emacs 31.0.91. The source links point to the installed package revisions, rather than assuming a newer checkout would behave the same way.

What Doom’s startup message measures

The first useful finding was in Doom’s own reporting code. Its package count comes from this expression:

(- (length load-path) (length (get 'load-path 'initial-value)))

That counts added search-path entries. It does not mean that 286 packages had been fully loaded. The time is Doom’s doom-init-time, measured from Emacs’s before-init-time, rather than an external stopwatch covering the whole application launch. The implementation makes both definitions explicit.

All times below use Doom’s timer. Temporary graphical instances ran my configuration with instrumentation in a temporary directory; my main session stayed open. Four dedicated reference starts reported 1.489, 1.397, 1.303 and 1.264 seconds, in that order. Another start of the original configuration, recorded while setting up the functional check, reported 1.618 seconds. These repeats did not reproduce the initial 1.713-second observation; I did not establish why that launch was slower.

Native compilation was available. The recorded reference starts had no garbage collections during the measured interval, and zero recorded GC time. Doom already defers collection during interactive startup, so raising a GC threshold again would not address the work I was seeing.

To locate that work, I timed load, require and process calls in separate instrumented runs. Those timings add overhead, so I used them to identify candidates, keeping the simpler reference runs for the startup comparisons.

Loading before I needed it

One require stood out: claude-code-ide, the package connecting Claude Code to Emacs. One instrumented run attributed about 467 ms to it, including its dependencies.

The package entry point requires its MCP integration. Following that into the handlers reaches diagnostics, which in turn attempts to load Flycheck. My timings showed those loads happening during startup. The time attributed to claude-code-ide already included the nested work; adding each dependency’s time to it would count the same work more than once.

I wanted the integration available from my leader menu. I did not need it loaded while Emacs was opening an unrelated buffer.

The durable change was one line:

 (use-package! claude-code-ide
+  :defer t
   :init

With :defer t, use-package skips adding the package to its implicit :load list, so the declaration no longer requires it at startup. That does not, by itself, create a way to load it later. Here, all nine commands I had bound had autoloads, including the menu command. Calling one could load the library containing its definition. The :defer handler distinguishes this from a numeric :defer, which schedules a load after an idle delay.

I kept the keybindings and my Ghostel backend selection in :init. They need to exist before the package loads. The call to claude-code-ide-emacs-tools-setup stayed in :config, to register the Emacs tools after loading.

That arrangement needed checking in a fresh instance. A package already loaded in my main session could hide a missing autoload. The check confirmed that the library was absent at startup, all nine bindings were available, and invoking the menu loaded the package. The Emacs MCP tools were registered and the backend was still Ghostel. This did not exercise a complete conversation with the Claude CLI.

The startup measurements were encouraging:

Configuration Starts Doom-reported time
Original, dedicated reference runs 4 1.264 to 1.489 s
Original, functional-check setup 1 1.618 s
Temporary variant deferring Claude 2 0.863 and 0.903 s
Real configuration, menu probes 2 0.767 and 0.799 s
Real configuration, final checks 2 0.768 and 0.873 s

The menu probes failed their functional assertions while the check was being developed. Their startup times still belong in the record: doom-init-time was set before those assertions ran. The later successful check establishes the behavior described above; a short startup time on its own would not establish that the integration still worked.

These are local observations from the review on 30 October 2026, with a small number of trials and no controlled cold-cache protocol. The table is not a promise that this edit always saves the difference between the fastest and slowest entries. It was enough evidence to keep the change, alongside the functional check.

The cost has also moved: the first use of the integration now has loading work to do. I did not measure that first-use latency separately.

Mise was another visible source of startup work, with 16 to 23 synchronous calls in the instrumented runs. Disabling it on its own produced one measurement of 1.267 seconds, within the dedicated reference range. Combining that with deferred Claude loading produced 0.781 seconds, also within the range reached by the deferral alone. Neither single trial established a repeatable benefit from disabling mise. I kept it enabled; only Claude’s loading was durably deferred in this startup experiment.

My configuration could now start in under a second on this Mac. That answered the practical question that sent me looking, even though the comparison with Arch remained uncontrolled.

The mode my hooks were missing

Startup had an obvious number attached to it. Some other customizations needed a different check: opening the kind of buffer I actually use.

In 2020, I wrote about binding M-RET to insert an Elixir pipe. I still want that shortcut. The function moves to the end of the line, inserts a new line and adds |> . But my customization targeted elixir-mode, while my enabled Doom module also uses elixir-ts-mode.

The current hook covers both:

(add-hook! '(elixir-mode-hook elixir-ts-mode-hook)
  (local-set-key
   (kbd "M-RET")
   #'bounga/insert-elixir-pipe-operator))

I checked it in an Elixir Tree-sitter buffer by invoking the binding: foo became foo followed by a newline and |> . The Elixir docset was also available there. On the Ruby side, checks in both ruby-mode and ruby-ts-mode confirmed the Ruby/Rails docsets and subword-mode; M-f stopped after My in MyLongClass.

Finding the hook names in the file would not have told me that. The customization can look perfectly familiar while missing the buffer where I expect to use it.

I also removed an explicit elixir-format hook before saving. My Doom configuration enables (format +onsave +lsp). The installed format module enables Apheleia on first-file use, and Apheleia maps both Elixir modes to mix-format, with Doom also supporting formatting through an available LSP formatter. The old hook was a separate formatting path that I no longer needed.

In an isolated directory with Elixir 1.20.2 and OTP 29, saving a buffer containing foo( 1,2 ) through Apheleia produced foo(1, 2) in both modes, in the buffer and on disk. No LSP server was involved, so this exercised Apheleia’s mix-format path.

A smaller naming error showed the same need to inspect the actual integration. I had written after! tramp-mode. Doom’s after! waits for a feature to load; the relevant feature is tramp. Changing it to after! tramp made the remote-path customization run in the targeted check. A familiar mode name was the wrong name for that job.

Letting an explicit mail choice stand

The mail changes deserve more care because I had already published my automatic signing and encryption setup. That article described the policy I wanted, but the implementation needed correction.

The old send hook chose between signing and signing plus encryption without first checking for an existing MML security instruction. It could replace a choice I had made explicitly while composing the message.

The current policy is:

Message state Automatic action
An explicit MML security instruction is present Leave it alone
No explicit instruction, and every recipient has a usable encryption key Request signing and encryption
No explicit instruction, and the key check fails Request signing only

The hook now looks for an existing secure tag in the message body before applying its default. Tests reproduce the earlier replacement of explicit signing, encryption and combined instructions, then confirm that the new hook preserves each one.

Reading Emacs’s own message helpers exposed another duplication. The body of my recipient parser matched message-all-recipients. My availability predicate followed the built-in message-all-epg-keys-available-p, including its epg-list-keys check. Giving those functions my own namespace had not given me different behavior.

Emacs also provides message-sign-encrypt-if-all-keys-available, but it does nothing when the key check fails. My policy includes signing in that case. I kept my own hook for that fallback and for preserving an explicit choice, replaced the parser with message-all-recipients, and switched the key check to mml-secure-find-usable-keys for encryption.

Having a key in a search result is insufficient. The MML implementation checks the relevant user ID and encryption capability, excluding expired or revoked candidates and disabled keys. An empty recipient list now fails my predicate as well.

This still does not establish that a key belongs to the person I mean, or replace the trust policy used when encrypting. Availability, usability and trust are separate questions. The hook requests an action; it cannot guarantee that signing or encryption will succeed when the message is sent.

The checks used simulated keys, covering To, Cc and Bcc, missing keys and unusable candidates. Across the two targeted suites, ten of fifteen tests failed against the earlier functions; all fifteen passed against the current definitions extracted from config.org. No real keyring was read and no mail was sent. The suites test the decisions in my code; they do not exercise an end-to-end encrypted exchange.

Deciding when to stop

The terminal setting needed no replacement. I removed the old vterm-shell assignment after moving to Ghostel. I did not replace it with an explicit ghostel-shell: its default reads SHELL, with /bin/sh as a fallback, and selects the shell I want.

I also made the fullscreen request explicit with fullboth instead of toggling it. I had not reproduced a fullscreen failure; the edit made the requested state explicit. It would be misleading to count it alongside the mail bug as one more thing that had been broken.

The distinction matters to how I read the resulting diff. Some lines fix observed behavior. Others follow a change in what I run, such as replacing vterm with Ghostel, or state a preference more clearly. Doom was doing useful work, including deferring GC and arranging formatting, that I needed to understand before adding more.

I can explain why Claude waits until first use, and I have checks for the editing and mail behavior I mean to keep. Those checks are what I want to rerun when the next package update makes a familiar customization worth revisiting.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.