Your Haskell Service Is Leaking Memory, and the Fix Is One Bang Away
Long-running Haskell services leak memory through lazy thunks in accumulators. Here's how BangPatterns, strict fields, and StrictData fix it — plus the WHNF trap and how to verify the fix with +RTS -s.
15 Apr 2026, 23:16 UTC

A Haskell service that sips memory in testing and balloons after a week in production almost always has the same root cause: laziness. Not a bug in your logic — the program computes the right answer — but unevaluated thunks piling up in an accumulator you thought was a number. The good news: this class of leak is well understood, and the fix is usually a handful of strictness annotations. The catch is knowing where to put them, because sprinkling bangs everywhere creates its own problems.
What a space leak actually looks like
Haskell evaluates expressions only when their results are demanded. When you write foldl (+) 0 [1..100000000], the accumulator isn't a running total. It's a growing chain of thunks — suspended computations like ((((0+1)+2)+3)+...) — that only collapse when the final result is forced. On a large input, that chain lives on the heap, and residency climbs linearly with input size.
The canonical fix is the strict left fold:
import Data.List (foldl')
-- Leaky: builds a thunk chain
totalLazy :: [Int] -> Int
totalLazy = foldl (+) 0
-- Strict: forces the accumulator each step
totalStrict :: [Int] -> Int
totalStrict = foldl' (+) 0foldl' evaluates the accumulator to weak head normal form (WHNF) on every iteration, so the accumulator stays a single machine word instead of a growing tree. You can verify the difference yourself: compile both versions, run with +RTS -s, and compare the "maximum residency" line in the RTS statistics. The lazy version's residency scales with input size; the strict version stays flat. Exact numbers depend on your GHC version and optimization flags, so measure on your own toolchain rather than trusting anyone's benchmark table — including this paragraph.
BangPatterns and strict fields
The same leak hides in your own data types. A long-running service that holds a record of counters, caches, or session state is an accumulator too. Two tools apply:
BangPatterns force a value at binding or pattern-match time:
{-# LANGUAGE BangPatterns #-}
go :: Int -> [Int] -> Int
go !acc [] = acc
go !acc (x:xs) = go (acc + x) xsStrict record fields force values at construction time:
data ServerStats = ServerStats
{ requestsServed :: !Int
, bytesSent :: !Int
, lastError :: Maybe Text -- stays lazy
}The ! on a field means: whenever a ServerStats value is constructed, evaluate that field to WHNF first. This is the standard defense for state that lives as long as the process does.
StrictData: making strictness the default
If you maintain a module full of accumulator-style records, annotating every field gets tedious and easy to forget during refactors. The StrictData extension (available since GHC 8.0) flips the default for every data type defined in that module:
{-# LANGUAGE StrictData #-}
data Metrics = Metrics
{ counters :: IntMap Int -- strict by default now
, label :: ~Text -- ~ opts back into laziness
}Fields are strict unless you mark them with ~. This is a good fit for modules containing service state, configuration snapshots, and metrics. It's a poor fit for modules defining streaming or syntax-tree types, where laziness is doing real work.
The trap: WHNF is shallow
Here's the subtlety that bites people after they've "fixed" their leaks. Strictness forces evaluation only to WHNF — the outermost constructor. A strict field of type Map k v guarantees the map's spine exists, but the values inside can still be thunks. A strict tuple !(a, b) forces the tuple constructor, not a and b.
Practical consequences:
- Use strict container types where available (e.g.,
Data.Map.Strict, strictTextrather than lazy) for long-lived state. - If you need full evaluation,
Control.DeepSeq.force(with anNFDatainstance) evaluates a structure completely, at the cost of traversing it. - When debugging, GHCi's
:sprintcommand shows which parts of a value are still thunks (_) — invaluable for confirming your annotation actually reached the thing that leaks.
Why not make everything strict, then?
Because laziness is a feature you're actively using, whether you notice or not. Strictness changes semantics in three ways that matter:
- Streaming breaks. Pipelines like
map f . filter p . take 10run in constant space because of laziness. Force intermediate structures and you materialize whole lists. - Short-circuiting gets slower.
any,and, andMaybe-style early exits rely on not evaluating the rest of the input. - Termination changes. A strict field that bottoms out (throws, loops) will crash at construction time, possibly in code paths that never used that field.
The right mental model: be strict in accumulators — things updated in a loop or held for the process lifetime — and stay lazy in producers and transformations.
A note on the optimizer, and how to check your work
GHC's strictness analyzer, active with -O, already makes many tight loops strict on its own. Sometimes your foldl benchmark "leaks" at -O0 and runs fine at -O2. Don't take that as safety: the analyzer can't see across module boundaries without inlining, and a small refactor can silently lose the optimization. Explicit annotations in library and state-management code are cheap insurance.
To verify your fix rather than hope for it:
- Run the service or a representative benchmark with
+RTS -sand confirm flat residency over growing input. For finer detail,-hcheap profiling (needs a profiling build) attributes residency to cost centres. - Inspect optimized Core with
-ddump-simpland check whether your accumulator is being forced — useful when you suspect the optimizer is or isn't doing what you think. - Confirm extension availability and syntax against the GHC user guide for your compiler version; behavior and flags do drift between releases.
The actionable takeaway: audit your long-lived state and your folds. Replace foldl with foldl', put bangs on accumulator fields (or enable StrictData in state modules), use strict container types, and then prove it with +RTS -s. One afternoon of this beats a weekly restart cron job.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.