I was three weeks into a little in-memory job queue service written in Haskell — nothing fancy, just a Warp server holding a TVar of pending jobs so I could poke at scheduling and retry logic without touching a database. Every time I changed a line, I killed the process, ran cabal run again, and re-seeded the queue by hand with a handful of curl commands before I could see whether my change did anything. By the fortieth restart that day I was actively resentful of my own tooling, which is usually a sign I should go read something instead of keep grinding.

What I read was a post by a Haskell (and secretly Lisp-envious) blogger who goes by kqr, called “A curmudgeon tries a language server.” The gist of it: Lisp programmers get to edit a running process directly — no restart, no recompile, sometimes not even a crash when something throws, because Common Lisp's condition system lets you patch the bug and resume the stack from where it broke. kqr doesn't get that in Haskell, but tried to get partway there anyway, combining Eglot and the Haskell Language Server for code intelligence with ghcid and the foreign-store library to reload code without losing in-memory state.

I decided to set up the same combination for my own project instead of the differential-equations toy kqr used, mostly to see whether it would actually help with a backend service that has real concurrent state to lose on every restart, rather than a slope-field visualisation. Short version: yes, but the win is narrower and the setup is fussier than I expected going in.

Key takeaway: Eglot and HLS give you a nicer editor, but the thing that actually changes your workflow is ghcid plus foreign-store — it lets a long-running process (a server, a queue, a socket) survive a code reload with its in-memory state intact, at the cost of collapsing your project into one build component.

Chasing the Lisp REPL Feeling from a Haskell Backend

The Lisp workflow kqr describes isn't really about syntax or tooling polish, it's about where the code lives while you're working on it. A Lisp programmer edits definitions and sends them straight into the running image, so early in a project there might be no source file at all — just an evolving process in memory, the same way a young project's database schema often lives only in the running database before anyone bothers dumping it to a migration file. Haskell can't do that. There's no way to be “inside” a compiled Haskell process the way you're inside a Lisp image, and nothing in the mainstream exception machinery (EitherT, async exceptions) lets you resume a crashed computation from the exact point it failed.

What Haskell can do is make some of those crashes not happen in the first place, because the type system pushes a lot of what would be a runtime exception in a dynamically typed language into a compile error instead. That's a real advantage, but it's a different kind of advantage — it doesn't get you the live-editing feeling, it just means you need the live-editing feeling less often. I wanted to see how much of the remaining gap I could close for a service that has to keep running and keep its state while I hack on it.

So the plan, borrowed pretty directly from kqr's write-up, was: use a language server for the code-intelligence half (jump to definition, type-on-hover, real-time diagnostics), and use ghcid plus foreign-store for the reload-without-losing-state half. Where a Lisp programmer sends one function at a time to a live REPL, I'd be saving a file and letting the whole module recompile and swap in, while the actual server thread and its queue kept running underneath. Not the same thing, but maybe close enough to be useful for a project with concurrent state worth preserving.

Getting Eglot and HLS to Actually Talk, Through Nix and direnv

My project already used a flake.nix to pin GHC and dependencies, so the plan was to sandbox haskell-language-server and ghcid inside that flake rather than install them globally. That meant wiring up direnv with a one-line .envrc containing use flake, adding nix-direnv so Emacs wasn't reloading the whole Nix shell from scratch every time it touched the project, and installing the envrc Emacs package so buffers automatically pick up the flake's environment. Only after all of that does M-x eglot have any chance of finding the haskell-language-server-wrapper binary, because it isn't on my global PATH on purpose.

Even with all of that in place, Eglot needed babying. I had to manually run envrc-global-mode once before it would pick up the project environment for a buffer I already had open, and I still find myself running M-x eglot-reconnect after any change to the cabal file, because a new dependency doesn't get picked up by an already-running server. The restart itself takes a few seconds even for a small project, which doesn't sound like much until you're doing it every time you add a package while you're still figuring out what packages you need.

The one thing that nearly made me turn Eglot back off: it adds just enough latency to buffer navigation that Evil-mode movement commands stopped firing reliably for me. Nothing crashed, nothing errored — cursor jumps just occasionally didn't happen, which is a maddening kind of bug to chase because it looks like you fat-fingered a keybinding rather than like a tooling problem.

I eventually worked around most of the visible friction — setting eldoc-echo-area-use-multiline-p to nil stopped the type-signature popup from resizing my editing window, and turning off Flycheck in Haskell buffers removed a second, redundant source of diagnostics now that HLS was doing the same job. But the movement lag never fully went away, and it's the reason I don't run Eglot in every Haskell project I touch, only the one where I've decided the trade is worth it.

The Reload-Without-Losing-State Trick: ghcid and foreign-store

Language server aside, the more interesting half of this setup is ghcid. It watches your source tree, recompiles on save, and — the useful bit — can run a specific function once the compile succeeds, instead of just reporting errors. The catch is that ghcid drives a single ghci session, and ghci can only track one cabal component at a time. If your project splits into a library, an executable, and a test suite the normal way, ghcid can reload whichever one it's watching, but touching any of the others forces a full process restart. That's exactly why kqr collapsed their toy project into a single executable component, and I did the same for mine — not something I'd do for anything I intended to ship, but fine for a project still finding its shape.

Recompiling and re-running main on every save is the easy part. The hard part is that a naive main throws away everything the process was holding, which for a job queue means every test job I'd pushed in during manual testing. Here's the naive version I started with:

-- Main.hs, first attempt: state dies on every reload"/>

That comment placeholder aside, here's the real broken version I actually ran:

module Main (main) where

import Control.Concurrent.STM (newTVarIO)
import Network.Wai.Handler.Warp (run)
import JobQueue (jobQueueApp)

main :: IO ()
main = do
  queue <- newTVarIO ([] :: [Job])
  putStrLn "Starting job queue on :8080"
  run 8080 (jobQueueApp queue)

Every reload calls newTVarIO fresh, so every job I'd queued for testing vanished the instant I saved an unrelated file. The fix is the same one kqr uses: stash the long-lived values in foreign-store, a library that keeps a value alive in a global table across GHCi reloads, and only cancel and restart the part of the process that actually needs to restart — the running server thread — while leaving the queue itself untouched:

module Main (main) where

import Control.Concurrent.Async (async, cancel)
import Control.Concurrent.STM (newTVarIO, readTVarIO)
import Foreign.Store
import Network.Wai.Handler.Warp (run)
import JobQueue (Job, jobQueueApp)

queueStore, serverStore :: Word32
queueStore  = 0
serverStore = 1

main :: IO ()
main = do
  queue <- lookupStore queueStore >>= \case
    Nothing -> do
      q <- newTVarIO ([] :: [Job])
      _ <- writeStore (Store queueStore) q
      pure q
    Just s -> readStore s

  lookupStore serverStore >>= \case
    Nothing -> pure ()
    Just s  -> readStore s >>= cancel

  server <- async (run 8080 (jobQueueApp queue))
  _ <- writeStore (Store serverStore) server
  pure ()

The mechanism is straightforward once you see it: check whether a store slot already has a value, reuse it if so, only create fresh state on the very first run. The old server thread gets cancelled and a new one takes its place, but the TVar holding the queue never gets recreated, so anything I pushed into it during testing survives the reload. I tried the higher-level Rapid library first, since it wraps a lot of this boilerplate, but couldn't get stdout forwarding and clean shutdown working reliably with it, and ended up back on plain foreign-store and Async, same as kqr reports doing.

Wiring It Into a Warp-Based Job Queue

With the reload mechanics sorted, the last piece is telling ghcid how to drive it. I ended up with an invocation close to kqr's, adjusted for my project's cabal file: ghcid --command "cabal repl --repl-options='-fobject-code -ferror-spans'" --restart jobqueue.cabal --test Main.main. That tells ghcid which file to watch for a full restart (the cabal file, since dependency changes need one), and which function to call once a compile succeeds.

In practice this means I can leave a terminal running ghcid in one corner, hammer the queue with curl in another, and edit source in Emacs. Save a change to how retries are scheduled, watch ghcid recompile in under a second, and the same jobs I'd queued five minutes earlier are still sitting in the TVar when the new server thread comes up. That part genuinely feels closer to the Lisp workflow than anything else I've tried in Haskell — not because I'm editing a live image, but because the illusion of continuity holds up well enough that I stopped thinking about restarts as a cost.

Worth being clear about what's actually happening here: ghcid isn't hot-patching running code the way a Lisp REPL does. It's still doing a full recompile and swapping in an entirely new process image for everything except the values you deliberately stashed in foreign-store. The state survives; the code doesn't stay “the same running thing” in any deeper sense.

The rough edge I never fully solved: ghcid sometimes just wouldn't reload at all, silently, with no error printed anywhere. The fix, when I found it by accident, was to launch it from inside an already-activated Nix development shell rather than trusting direnv to inject the environment on its own. I still don't know why direnv wasn't enough there, and neither did kqr when they hit the same thing, which is a slightly unsettling amount of shared confusion for two independent people running the same command.

What This Actually Buys You (and Where It Doesn't)

Stepping back, the language-server half of this setup is a genuine but modest upgrade — better hovers, real jump-to-definition, inline diagnostics that replace what Flycheck used to do worse. The ghcid-and-foreign-store half is the part that changes how I actually work, because it turns “restart and re-seed test data” from something I did forty times a day into something I don't think about at all. For a backend service with any meaningful in-memory state during development — caches, queues, connection pools you don't want to keep re-establishing — that's a real, measurable reduction in friction, not just a nicer editor.

What it doesn't do is give me anything like a real REPL. I can't evaluate an arbitrary expression against the live process the way a Lisp or even a Python developer would, so instead I write the experiment as an HSpec test and let ghcid run the suite before it restarts the server. That's a fine substitute for “does this function do what I think,” but it's not a substitute for exploratory poking, and it never will be as long as the underlying mechanism is recompile-and-swap rather than genuine live patching.

“How is this not a solved problem?”
— Joel, in the comments on kqr's original post

That comment stuck with me, because it's a fair question from outside the Haskell bubble — Python plus VS Code gets you most of this for free, no Nix, no flake, no direnv dance. I don't think the honest answer is that Haskell tooling is uniquely bad; it's that Haskell's compile-then-run model and its lack of an image-based runtime make the underlying problem genuinely harder, and the ecosystem's fix is a set of separately-maintained tools glued together by whoever's willing to do the gluing, rather than one team owning the whole experience end to end.

Would I Set This Up Again?

For this job queue project, yes, without much hesitation — the state-preserving reload loop paid for its own setup cost within the first afternoon of not re-seeding test data by hand. But I wouldn't reach for the same setup on anything past the toy-project stage, because collapsing library, executable, and test code into one cabal component to satisfy ghcid's single-component limitation is a real structural compromise, not a free lunch. The day this project needs a proper library boundary for reuse elsewhere, I'll be back to plain restarts, and I've made my peace with that trade for now. If you're evaluating this for your own backend work, the question worth asking isn't whether Eglot and HLS are good — they're fine, a bit laggy, and worth trying — it's whether you have enough in-memory state worth preserving across a reload to justify the Nix and cabal-structure concessions that make foreign-store's trick actually work.

Sources: kqr — A curmudgeon tries a language server