Learn Neovim - Profile & Performance Optimization Startup Time
Series/Learn Neovim/Episode 23
Episode 23 of 28

Learn Neovim - Profile & Performance Optimization Startup Time

After all the features are installed, it is time to make sure Neovim stays flying: learn to measure startup time with `--startuptime` and `:Lazy profile`, then apply advanced lazy-loading based on event, command, keymap, and filetype so 50+ plugins never slow down the first 50 milliseconds.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

After discussing debugging with nvim-dap in episode 22 — turning Neovim into an interactive debugger for tracing bugs — in this episode we switch from the feature side to the feel side: speed. Many people who have installed dozens of plugins then complain that Neovim "feels heavy", when the problem is not the plugins themselves, but the fact that all of them are loaded upfront, without a strategy.

In episode 9 we built the lazy.nvim foundation and touched the basic lazy-loading concept. Episode 23 is the next level: we will learn to objectively measure how long Neovim needs to wake up (--startuptime and :Lazy profile), interpret the results like an SRE reading a latency graph, then apply advanced lazy-loading techniques — event, cmd, keys, and ft — to keep startup time under 50ms even when your config contains 50+ plugins.

Why is this important in real work? An engineer opens and closes the editor dozens of times a day, especially when running quick terminal commands. Every 100ms you save at startup means hundreds of milliseconds of waiting per day — and while waiting for the editor, your flow of thought is also interrupted. Think of it like a car engine: good tuning does not make the car faster, it makes sure no energy is wasted.

Main Discussion

Why Is Startup Time a Metric That Must Be Measured?

The same principle as observability in the server world: you cannot optimize something you do not measure. The theory that "my config is fast" without data is just a guess. On the other hand, many also sacrifice features just to chase a small startup number — yet 10 plugins active when needed can still feel fast as long as they are lazy-loaded correctly.

Realistic targets for a modern Neovim with many plugins:

Config CategoryStartup Time (plain nvim)Notes
Bare minimum (no plugins)5 – 15 msOnly options + keymaps
Few plugins (10 – 20)15 – 40 msBasic lazy-loading is enough
Many plugins (50+)40 – 100 msNeeds disciplined lazy-loading
"Careless" config200 ms+A sign of many eager-loaded plugins

The realistic target of this episode: under 50ms with 50+ plugins. Not 5ms, because below 50ms humans can no longer tell the difference with the naked eye — that is the sweet spot between features and speed.

Measuring with nvim --startuptime

The most basic method, available for a long time, is the --startuptime flag. It writes a log of every boot step's timing to the file you specify:

Mengukur startup time
nvim --startuptime /tmp/startup.log && head -n 30 /tmp/startup.log

Notice how it is written: --startuptime comes before opening the file. If you write it after the filename, the flag will not be recognized. Then look at the top of the log:

/tmp/startup.log — contoh output
times in msec
 clock    self+sourced   self:  sourced script
 clock    self:  sourced script
  0.011  0.011:  sourcing /usr/bin/nvim
  0.361  0.350:  sourcing /usr/share/nvim/runtime/filetype.lua
  0.459  0.098:  sourcing /usr/share/nvim/runtime/syntax/synload.vim
  0.557  0.098:  sourcing /usr/share/nvim/runtime/syntax/syntax.vim
  1.120  0.563:  sourcing /usr/share/nvim/runtime/filetype.vim
  2.245  0.743:  sourcing /usr/share/nvim/runtime/ftplugin.vim
  4.872  0.640:  sourcing /usr/share/nvim/runtime/plugin/gzip.vim
  5.590  0.418:  sourcing /usr/share/nvim/runtime/plugin/netrwPlugin.vim
  ...
 81.412  1.120:  sourcing /home/user/.config/nvim/plugin/lualine.lua
 84.290  0.211:  sourcing /home/user/.config/nvim/plugin/telescope.lua
 95.118  0.998:  sourcing /home/user/.config/nvim/plugin/treesitter.lua
...
Total time: 137.421 msec

There are three important columns to understand:

  • clock — cumulative time since Neovim started (milliseconds). This shows where we are on the boot timeline.
  • self — pure time spent loading this file itself (without the files it sources).
  • self+sourcedself plus all files that this file also sources.

Note

Notice an interesting pattern: the plugin/*.lua lines appearing after ~80ms usually indicate the config loads many plugins eagerly (executed immediately at start). This is the pattern we are targeting for removal. Lines with a high self (e.g. 15ms for one file) indicate heavy work — usually a big plugin, chained requires, or a build running during load.

Every time you finish changing the config, make it a ritual: run the command above, open the log, and ask one question — "why should this file be loaded in the first second?"

Measuring with :Lazy profile

--startuptime gives you raw detail, but to know how long each plugin contributes, use lazy.nvim's built-in profiler. Open Neovim then run:

Profil per-plugin dari lazy.nvim
:Lazy profile

The output is a table grouping boot time per plugin:

Contoh output :Lazy profile
Plugin                            Loaded at    Load time
plugins/colorscheme.lua           0.8ms        1.4ms
plugins/which-key.lua             3.1ms        0.9ms
plugins/lualine.lua               78.2ms       6.8ms
plugins/telescope.lua             81.4ms       0.2ms
plugins/treesitter.lua            95.1ms       8.2ms
plugins/cmp.lua                   98.6ms       3.4ms
plugins/lsp.lua                   101.7ms      12.1ms
...
Total startup time: 137.421ms

Read it like an SRE reading pprof: look for plugins with the earliest Loaded at and the largest Load time at the same time. Those two plugins are the prime suspects. As a complement, :Lazy stats (or :Lazy load) shows the hit/loading count per plugin in the current session — helping you see which plugins are actually used and which are just "installed then forgotten".

Tip

You can also measure startup repeatably for before/after comparison via a shell loop. Run it several times and take the median — a single measurement is heavily influenced by disk and CPU noise:



for i in $(seq 1 5); do nvim --headless -c 'quit' +'lua vim.defer_fn(vim.cmd.quit, 200)' 2>/dev/null; done

Advanced Lazy-Loading Techniques

In episode 9 we learned that lazy.nvim can load plugins lazily. What we will deepen here is four lazy-loading triggers that form the "main weapons" of performance:

TriggerOption in lazy.nvimTriggered whenBest suited for
Eventevent = { "VeryLazy" }A Neovim event occursPlugins needed during the session, but not in the first second (statusline, which-key, bufferline)
Commandcmd = { "Telescope" }:Command is invokedCommand-based plugins (telescope, Mason, LazyGit)
Keymapkeys = { ... }A keymap is pressedPlugins accessed via shortcuts (flash, oil, harpoon)
Filetypeft = { "go" }A buffer filetype is detectedLanguage-specific plugins (treesitter parsers, formatters, LSP servers)

The principle is one: never load code that is not yet needed. A plugin should only execute when the user (or the system) actually asks for it. Let's look at complete example specs for each trigger, wrapped in one code-group for easy comparison.

{
  "folke/which-key.nvim",
  event = "VeryLazy",          -- dimuat setelah startup selesai
  opts = {},
},
{
  "nvim-lualine/lualine.nvim",
  event = "VeryLazy",          -- statusline tidak perlu di milidetik pertama
  dependencies = { "nvim-tree/nvim-web-devicons" },
  opts = {},
},

Notice the important pattern in the examples above: keys and cmd can stand alone as triggers — if you define keys, lazy.nvim creates the keymap for you at the same time, and the plugin is only loaded when that keymap is pressed. No need to write vim.keymap.set manually for plugins managed by lazy.nvim. This is the behavior that distinguishes lazy.nvim from other plugin managers: keymap and lazy-loading are one and the same thing.

Disabling Unused Built-in Plugins

Besides third-party plugins, Neovim also ships built-in runtime plugins (netrw, gzip, zipPlugin, tarPlugin, tohtml, etc.). Though small, together they contribute a few milliseconds and you never use them — netrw especially often loads in the early seconds because it is the fallback file explorer. lazy.nvim provides an official way to disable them via performance.rtp:

lua/plugins/core.lua — nonaktifkan plugin bawaan
{
  "folke/lazy.nvim",
  performance = {
    rtp = {
      disabled_plugins = {
        "netrwPlugin",   -- pakai oil.nvim / neo-tree sebagai gantinya
        "gzip",
        "zipPlugin",
        "tarPlugin",
        "tohtml",
        "matchit",
      },
    },
  },
},

After this, your --startuptime will show the plugin/netrwPlugin.vim lines and similar gone from the log. Saving milliseconds one by one — that is how serious tuning works.

Case Study: Bringing 50+ Plugins Under 50ms

Let's combine all the techniques above in one case study. The reference config has 54 plugins. Here is the before/after comparison:

Plugin GroupBefore (eager)After (lazy)Trigger
colorscheme (tokyonight)8.4 ms0.9 msVeryLazy
which-key3.1 ms0.8 msVeryLazy
lualine + bufferline11.2 ms1.1 msVeryLazy
telescope + fzf-native7.6 ms0.4 mscmd + keys
treesitter + main parsers14.3 ms2.2 msevent = { "BufReadPre", "BufNewFile" }
LSP (mason + lspconfig + server)22.8 ms3.1 msevent per filetype
nvim-cmp + snippets9.4 ms1.8 msevent = "InsertEnter"
dap + dap-ui6.7 ms0.3 mskeys + cmd
Total startup~210 ms~38 ms

From ~210ms to ~38ms without losing a single feature. The secret is not removing plugins, but deferring everything. Notice that nvim-cmp is triggered by event = "InsertEnter" — completion is only needed when you start typing, not when opening a buffer. This is the mindset you must internalize.

Diff: From Eager to Lazy

Here is a real diff example when a spec is changed from eager-loaded to lazy-loaded — a pattern you will apply often:

lua/plugins/telescope.lua — sebelum vs sesudah
 {
   "nvim-telescope/telescope.nvim",
   dependencies = { "nvim-lua/plenary.nvim" },
-  config = function()                      # [!code --:3]
-    require("telescope").setup({})
-  end,
+  cmd = "Telescope",                    # [!code ++:1]
+  keys = {                              # [!code ++:4]
+    { "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Find Files" },
+    { "<leader>fg", "<cmd>Telescope live_grep<CR>", desc = "Live Grep" },
+  },
+  opts = {},                            # [!code ++:1]
 }

Important

The golden rule of lazy-loading: a plugin must not be required at the top level of other config files. If telescope is lazy-loaded but there is a require("telescope") in lua/config/keymaps.lua executed at startup, the plugin still loads in the first second — your lazy-loading is wasted. Always call require inside functions (callbacks, keymap functions), not at the module top level.

Common Pitfalls

  1. Over-lazy-loading that breaks the plugin. Not all plugins are safe to lazy-load. Plugins that need to register autocmd or command from the start (e.g. plugins wrapping nvim_create_autocmd for certain events) will lose their function if lazy-loaded too late. The solution: understand the plugin's needs, and for ones that need to be present earlier use event = "VeryLazy" — not cmd or keys.

  2. Abusing vim.defer_fn. Many try to "cheat" startup with vim.defer_fn(function() require("plugin").setup() end, 0). This does not move work out of startup — it only delays it a few milliseconds, then executes in front of your UI at first interaction, creating a confusing freeze. If you really need async, use the right event (UiEnter, VeryLazy), not delayed synchronous work.

  3. Lazy-loading LSP too aggressively. Triggering the LSP server based on keys (e.g. only running when gd is pressed) makes goto-definition feel slow and hover stuttery. LSP should be triggered by event = { "BufReadPre", "BufNewFile" } or per ft, because the buffer is already open before you can press anything.

  4. ft that never triggers. Filetype-based lazy-loading fails silently if filetype detection is disrupted — e.g. filetype off in the config, or a buffer opened without an extension (:e file with no extension). Verify with :set filetype? when the buffer is open.

  5. Optimizing without data. The most common and most severe: removing plugins or guessing without first running --startuptime and :Lazy profile. Always measure, then change, then measure again — that is the correct observability cycle.

Startup Audit Checklist

Make this list a habit every time you add a new plugin:

NoAudit QuestionYes
1Does this plugin have a lazy-loading trigger (event/cmd/keys/ft)?
2Is there no top-level require of this plugin in other modules?
3Does :Lazy profile show Load time < 2ms for non-essential plugins?
4Is total startup under the target (< 50ms)?
5Is there no vim.defer_fn delaying synchronous work?
6Are unused built-in plugins disabled?

Closing

In episode 23 we learned that speed is not luck, but the result of measurement and discipline. We understood how to read nvim --startuptime and :Lazy profile to find the biggest time contributors, then destroyed them with the four lazy-loading triggers — event, cmd, keys, and ft — until a config with 54 plugins could start under 50ms without losing any features.

The most important takeaway: lazy-loading is a mindset, not just a config option. Every new plugin you add must answer one question before being installed: "when is this plugin really needed?" As long as you stay consistent with that question, config performance will be maintained as the number of plugins grows.

In episode 24, we will take this journey to the next level: managing dotfiles — storing your entire Neovim config in a Git repository, making sure your config runs on Linux, macOS, and Windows from a single source, and automating bootstrap on new laptops or servers. A skill that will save you every time you switch machines. See you in the next episode, and do not forget — measure first before changing!

Learn Neovim - Profile & Performance Optimization Startup Time | Learn Neovim