background-shape
Fast Code Navigation in Neovim, Telescope and fzf-lua Patterns
April 8, 2024 · 6 min read · by Muhammad Amal programming

TL;DR — Telescope is the default choice for most devs, fzf-lua is the right choice if you work in monorepos over 500k files. Both share roughly the same keybinding patterns. The real productivity gain comes from live_grep, lsp_references, and resume, not from “find file.”

I switched between telescope and fzf-lua twice before settling on a hybrid. Telescope handles 90% of my navigation. fzf-lua takes over when I’m grepping through a 2M-line monorepo and telescope starts to feel sluggish.

This post covers the workflow patterns that actually matter. I’ll skip the “you can also use it to browse colorschemes” features. Senior devs care about: jumping to a file you half-remember the name of, finding every call site of a function, grepping with proper regex support, and resuming where you were.

Both tools nail those. The difference is in defaults and how they behave on large codebases.

Why a Fuzzy Finder Beats a File Tree

I used to keep a file tree open. I don’t anymore. Once your project crosses ~100 files, the tree becomes a navigation tax, you scroll through directories looking for the file you already knew the name of.

A fuzzy finder inverts that. You type three characters of the filename, hit Enter, and you’re there. The tree, if you keep one at all, becomes a tool for understanding structure, not for opening files.

Telescope and fzf-lua both implement this pattern well. The minimum useful keymap set is small:

local map = vim.keymap.set
map('n', '<leader>ff', '<cmd>Telescope find_files<cr>')      -- by filename
map('n', '<leader>fg', '<cmd>Telescope live_grep<cr>')       -- by content
map('n', '<leader>fb', '<cmd>Telescope buffers<cr>')         -- open buffers
map('n', '<leader>fr', '<cmd>Telescope resume<cr>')          -- last picker
map('n', '<leader>fs', '<cmd>Telescope lsp_document_symbols<cr>')
map('n', '<leader>fw', '<cmd>Telescope grep_string<cr>')     -- word under cursor

Five keymaps cover 95% of navigation. <leader>fr (resume) is the one most people don’t bind and then can’t live without once they do, it reopens whatever picker you closed with the same query and cursor position.

Telescope, the Practical Config

A telescope config that doesn’t fight you:

-- lua/plugins/telescope.lua
return {
  { 'nvim-telescope/telescope.nvim',
    tag = '0.1.5',
    dependencies = {
      'nvim-lua/plenary.nvim',
      { 'nvim-telescope/telescope-fzf-native.nvim', build = 'make' },
    },
    cmd  = 'Telescope',
    keys = {
      { '<leader>ff', '<cmd>Telescope find_files<cr>' },
      { '<leader>fg', '<cmd>Telescope live_grep<cr>'  },
      { '<leader>fb', '<cmd>Telescope buffers<cr>'    },
      { '<leader>fr', '<cmd>Telescope resume<cr>'     },
    },
    config = function()
      local telescope = require('telescope')
      local actions   = require('telescope.actions')

      telescope.setup({
        defaults = {
          path_display = { 'truncate' },
          layout_strategy = 'horizontal',
          layout_config   = { horizontal = { preview_width = 0.55 } },
          mappings = {
            i = {
              ['<C-j>'] = actions.move_selection_next,
              ['<C-k>'] = actions.move_selection_previous,
              ['<C-q>'] = actions.send_to_qflist + actions.open_qflist,
              ['<esc>'] = actions.close,
            },
          },
          file_ignore_patterns = { '%.git/', 'node_modules/', '%.lock' },
        },
        pickers = {
          find_files = { hidden = true },
          live_grep  = { additional_args = function() return { '--hidden' } end },
        },
      })

      telescope.load_extension('fzf')
    end },
}

A few details worth flagging. telescope-fzf-native is mandatory, not optional, it’s the C extension that makes fuzzy matching fast. Without it, large repos feel laggy. The build = 'make' instruction tells lazy.nvim to compile it on install.

<C-q> sends matches to the quickfix list. This is the killer feature most people miss. Run live_grep for some pattern, hit <C-q>, and now you have every match in a navigable list. Walk it with :cn and :cp (or your mapped equivalents) and edit each one. This is how you do project-wide refactors without leaving the editor.

<esc> closing in insert mode is opinionated. Telescope’s default puts you in normal mode first, then you press it again to close. I find that pointless. Override it.

When fzf-lua Wins

I moved to fzf-lua for one project, a backend monorepo with about 1.4 million lines of code. Telescope’s live_grep started taking 2-3 seconds to update per keystroke. fzf-lua, which spawns the fzf binary directly and streams ripgrep output into it, stayed responsive.

The config is similar but the philosophy is different. fzf-lua is closer to “fzf with Neovim integration” than “Lua-native picker.” It feels snappier on huge repos because the heavy lifting happens in compiled binaries.

-- lua/plugins/fzf-lua.lua
return {
  { 'ibhagwan/fzf-lua',
    dependencies = { 'nvim-tree/nvim-web-devicons' },
    cmd  = 'FzfLua',
    keys = {
      { '<leader>ff', '<cmd>FzfLua files<cr>'      },
      { '<leader>fg', '<cmd>FzfLua live_grep<cr>'  },
      { '<leader>fb', '<cmd>FzfLua buffers<cr>'    },
      { '<leader>fr', '<cmd>FzfLua resume<cr>'     },
    },
    opts = {
      winopts  = { preview = { layout = 'horizontal', horizontal = 'right:55%' } },
      grep     = { rg_opts = '--column --line-number --no-heading --color=always --smart-case --hidden' },
      previewers = { builtin = { syntax_limit_b = 1024 * 100 } },
    } },
}

The keymap structure intentionally matches telescope’s. If you ever swap tools, you don’t retrain muscle memory.

The choice between them: telescope if you want a richer Lua API and more extensions, fzf-lua if you want raw speed on big repos. Neither is wrong.

The Workflows That Pay Off

Three workflows separate people who use a fuzzy finder from people who use a fuzzy finder well.

Grep, then edit in quickfix. Search for a symbol with live_grep. Send to quickfix. Run :cdo s/oldname/newname/gc to edit every match interactively. This replaces about 80% of what you’d use a JetBrains “find usages” refactor for.

LSP references through telescope. vim.lsp.buf.references() opens the quickfix list by default, which is fine but ugly. Set up telescope’s LSP integration and you get a fuzzy-searchable, previewable list of every reference.

vim.keymap.set('n', 'gr', '<cmd>Telescope lsp_references<cr>')

That’s the one keymap I use more than any other when reading unfamiliar code. Cursor on a function, gr, scroll through every caller with live preview.

Symbol pickers as a fallback for navigation. When I don’t know which file a function lives in, lsp_workspace_symbols finds it. It’s slower than find_files (it queries the LSP server) but it works on symbol names, not filenames. For Go interfaces especially, this is invaluable.

I covered the broader rationale for the keyboard-driven workflow in Tmux and Neovim together. Navigation is the foundation, debugger and terminal panes layer on top.

Common Pitfalls

A few things to avoid.

  • Indexing the wrong directory. find_files runs from cwd. If you launched nvim from ~, it’ll try to fuzzy-find your entire home directory. Always start nvim from your project root, or use a project.nvim-style plugin to switch cwd.
  • Skipping fzf-native. Telescope without the C extension is 5-10x slower on matching. Install it. The make build dependency is worth it.
  • Using grep_string when you meant live_grep. grep_string runs once with the current word. live_grep updates as you type. Bind both, use the right one.
  • Forgetting --hidden. Default ripgrep skips dotfiles. If you’re working in a project where config lives in .github/ or .config/, you’ll think files are missing. Add --hidden to your grep args.
  • Treating telescope as a file manager. It’s not. If you need to rename or move files, use a real file manager (oil.nvim, neo-tree.nvim) or shell commands.

Wrapping Up

Pick one of telescope or fzf-lua, learn the five keymaps above, and use resume every time you accidentally close a picker. That’s 90% of the value.

The remaining 10% is the quickfix integration, which is where this stops being a “find file” tool and starts being a refactoring tool. Once you’ve done a project-wide rename via live_grep<C-q>:cdo s/.../.../gc, you won’t go back.

The telescope.nvim README lists every picker and option, and the Neovim quickfix docs cover the underlying list mechanism that makes all of this work. Both worth a read once your fingers know the basics.