Haskell World logo Haskell WorldWrite code, build worlds
Systems

How to Find and Fix a Haskell Space Leak in Production

Your service starts at 80 MB of resident memory, and three days later the container gets killed at 2 GB. Nothing in the logs looks wrong, every request still returns the right answer, and the code has no obvious global cache.

Laptop showing a rising heap profile graph beside a terminal running a Haskell service

Your service starts at 80 MB of resident memory, and three days later the container gets killed at 2 GB. Nothing in the logs looks wrong, every request still returns the right answer, and the code has no obvious global cache. That pattern is the classic Haskell space leak, and it is one of the few production problems that the type checker will never warn you about.

A space leak is not a leak in the C sense. The memory is still reachable, so the garbage collector is doing its job. The problem is that your program is holding on to far more than it needs, usually as unevaluated thunks that pile up until something finally forces them. This guide walks through the order I use to find one, from cheap checks to heap profiles, and the fixes that actually stick.

What a Haskell Space Leak Looks Like in Practice

Most leaks fall into one of three shapes. Knowing which one you have saves hours of guessing.

  • Thunk build-up. A lazy accumulator such as a counter in an IORef or a foldl grows a chain of (+1) applications instead of a number. Memory climbs steadily with traffic.
  • Retained structure. A small value keeps a large one alive. Taking the length of a list you also need later forces the whole list into memory at once.
  • Lazy fields in long-lived state. A record stored in a TVar or MVar gets updated with modify, and each field holds a reference to the previous version.

The common thread is laziness meeting long-lived data. If you want a refresher on why evaluation is deferred in the first place, our piece on why lazy evaluation in Haskell actually matters covers the model in more depth.

Confirm the Leak Before You Touch Any Code

Rising memory is not always a leak. GHC's copying collector can legitimately use twice the live data, and a big burst of requests can grow the heap without any bug. So measure live data, not resident memory.

Run the binary with +RTS -s (link with -rtsopts first) and look at maximum residency. Then run the same workload twice as long. If maximum residency stays flat, you do not have a leak. If it doubles, you do. Our walkthrough on reading GHC RTS -s output before tuning GC explains each line of that report.

In a running service, expose the same numbers through GHC.Stats with +RTS -T and graph gcdetails_live_bytes over a day. A sawtooth that returns to the same floor is healthy. A floor that keeps rising is your leak.

Profile the Heap to Find the Space Leak Source

Once you know the leak is real, you need to know what is on the heap. Since GHC 9.2 you can do a lot without rebuilding everything for profiling.

Start with info table profiling

Build with -finfo-table-map -fdistinct-constructor-tables and run with +RTS -hi -i0.5. This samples the heap every half second and attributes closures to source locations, with almost no runtime cost. Open the resulting .eventlog in eventlog2html and look for the band that grows without bound.

Fall back to classic profiling when needed

If the info table view is too fine-grained, build with cabal build, enable-profiling and run +RTS -hy to break memory down by type, then -hc for cost centre. A growing band labelled THUNK or * points to thunk build-up. A growing ByteString or Map band points to retained structure.

FlagBreaks heap down byRebuild needed
-hTClosure typeNo
-hiSource locationInfo table flags only
-hyHaskell typeFull profiling
-hcCost centreFull profiling

Fix Thunk Build-Up With Strictness in the Right Place

The fix for an accumulating thunk is to force the value at the moment you store it. Sprinkling bang patterns everywhere is not the answer, and it can slow hot paths down.

  1. Replace foldl with foldl' from Data.List or Data.Foldable.
  2. Replace modifyIORef with modifyIORef', and modifyTVar with modifyTVar'.
  3. Use Data.Map.Strict and Data.IntMap.Strict for any map that lives longer than a single request.
  4. Put a bang on the accumulator argument of hand-written loops, as in go !acc (x:xs).

Be aware that modifyIORef' only forces to weak head normal form. If the value is a pair or a record, the outer constructor gets evaluated but the fields may still be thunks. That is why the next fix matters.

Make Long-Lived Records Strict by Default

Application state, metrics counters and connection tables are the places a Haskell memory leak hides longest. Give those types strict fields with a bang on each one, or enable {-# LANGUAGE StrictData #-} for the whole module that defines them.

A small worked example: a Stats record with three lazy Int fields, updated 500 times a second in a TVar, gained roughly 40 MB an hour in one service I reviewed. Adding StrictData to the module and switching to modifyTVar' flattened it to a constant 3 KB. If you share state across threads this way, the patterns in our guide to Haskell STM for shared state pair well with strict records.

For nested data you truly need fully evaluated, Control.DeepSeq and force work, but they walk the whole structure every time. Use them at boundaries, such as just before writing into a cache, not inside loops.

Stop Retaining Data You Already Used

Retained structure leaks are about sharing. If one expression consumes a list and another still holds a reference to its head, the whole list stays in memory.

  • Compute sum and length in a single strict fold instead of two passes over the same list.
  • Stream large inputs with conduit, streaming or lazy ByteString reads processed chunk by chunk, rather than reading the whole file and passing it around.
  • Copy small slices out of large buffers with Data.ByteString.copy or Data.Text.copy, so a 40-byte key does not pin a 64 KB chunk.
  • Watch closures stored in long-lived maps. A handler that captures the full request record keeps every header and body alive.

Guard Against a Regression

A leak you fixed once tends to come back during a refactor. Two cheap guards help. First, run your integration tests with +RTS -M256m so a leak turns into a test failure instead of a slow production incident. Second, keep the live bytes graph on your dashboard and alert when the post-GC floor rises more than 20 percent over 24 hours.

The nothunks library is also worth a look for state types. It lets you assert in tests that a value contains no unexpected thunks, which catches lazy fields before they ship.

FAQ

Is a Haskell space leak the same as a memory leak?

No, a space leak means memory is still reachable but held for longer or in larger form than necessary. The garbage collector cannot free it because your program still references it, often through unevaluated thunks.

Should I just turn on the Strict extension everywhere?

Usually not, because Strict changes the meaning of every binding in a module and can break code that relies on laziness. StrictData on modules that define long-lived types gives most of the benefit with far less risk.

Do I need a profiling build to find a space leak?

Not always, since -hT works on any binary and -hi only needs the info table flags. A full profiling build is useful when you need cost centre detail, but try the cheaper options first.

The Short Version

Confirm the leak with maximum residency, not resident memory. Profile with -hi or -hT to see what is growing. Then force values where they are stored, make long-lived records strict, and stop holding references to data you already consumed. Do those three things in that order and most leaks disappear in an afternoon.

AS
Adil Sato

Adil Sato teaches programming concepts at a community college and writes online guides for the questions students ask most in the second week of class, not the first. His focus is on making type systems, monads, and functional patterns understandable without reducing them to metaphors that break down the moment you try to use them for real work.

More posts by Adil

More in Systems

01doorganico 084goad 3hot8 4465456 after5internprogram ahandfulofblessings ahmadpc ahomcybersecurity allodialresources annodex antiquesandperiodcollectibles aotc asianworldkashmir beedlectrics bloggertemplatefree bluefeatheragency bodyweightsolution campaign08blog carfixadvisor catstreetcollective cbstvsports cnfanspurchasing committeeof500yearsofdignityandresistance covertopsdjs cs-kaiyuangame cuestacomunicaciontotal cxoutlet dancebetgame deskgearhub destinyvillastay detectmagazine di33 diamondsteelbuildings donmo dyanadropship emarsysfinance emimattl escapechan eskitarih evautoplay everythingweddings-orlando farmzone feedviewerapp foxstudiosscratch gamevui123 gleasongrade greetingsday hacklabasu iw-kaiyun jagoanproperti jilidskripsi joltbusinessfinance juegodepokergratis juegospucca kaeru-festival latestmehndidesigns lestanzediottaviano litemagazines localcommunityhero lustrsazan mariashebets menswheels newwebstechs1 nikkit ootheday oxigenvpn plurfitness prefetial probudgetcarpetcleaning psychicmrsmartin pyaten qvantor rejoignezlogis rexzbot riselayouts ruchin rwxqsgd5 sakaltime shibiaowang shop-bot skrtips strcoding tetapatrium topselller uneplanteartificielle vipertechcr webtech1223411 wo-kygaming yamaha-mekar yeknava yoybuysheets zenunwa yamaha-mekar jilidskripsi ankertoto ankertoto ankertoto ankertoto