When Laziness Leaks: Using GHC's StrictData to Keep a Haskell Service's Memory Flat
Long-running Haskell services often leak memory through thunk accumulation, not retained data. GHC's StrictData pragma flips record fields to strict-by-default—here's how to apply it safely and verify it with heap profiles.
12 Sept 2026, 16:32 UTC

A Haskell web service that runs fine in testing can slowly balloon in production: resident memory climbs hour after hour until the container gets OOM-killed. The usual suspect isn't a leak in the C sense—it's thunk accumulation. Laziness means a field you "updated" may actually hold a chain of unevaluated computations, and in a long-lived data structure those chains pile up. The useful takeaway: GHC's StrictData pragma (available since GHC 8.0) flips the default so record fields are strict unless you ask otherwise, which is often the right default for server-side state. But it's a blunt instrument, and you should confirm with a heap profile that thunks—not retained data—were your actual problem.
How laziness turns into a memory leak
When you write something like counter + 1 in Haskell, the result is typically not computed immediately. Instead, GHC allocates a thunk: a small heap object recording the suspended computation. In short-lived code this is fine—the thunk gets forced and freed quickly. The trouble starts with long-lived structures that are repeatedly "updated" but rarely forced.
The classic example is a server holding aggregate statistics in a record inside an IORef or TVar. Each request modifies a field, but if nothing forces the value, the field ends up pointing at a thunk that points at another thunk, and so on—one layer per request. Memory grows linearly with traffic even though the logical data is a single integer. Worse, the eventual evaluation of that deep chain can also overflow the stack.
StrictData: flipping the default
You can always annotate individual fields with a bang (!) to make them strict. StrictData simply makes strictness the default for every field of every data type declared in that module, and gives you ~ (a laziness annotation) as the escape hatch:
{-# LANGUAGE StrictData #-}
module Metrics where
data ServerStats = ServerStats
{ requestsServed :: !Int -- the ! is redundant under StrictData,
-- but harmless and self-documenting
, bytesSent :: !Int
, lastErrors :: ~[Text] -- explicitly lazy: appended rarely,
-- consumed as a stream elsewhere
}
Now constructing or updating a ServerStats forces the strict fields to weak head normal form (WHNF)—evaluated enough to expose the outermost constructor. For an Int, WHNF means fully evaluated, so no thunk chain can form. Note the caveat: WHNF only evaluates one layer deep. A strict field of type [Text] forces the list's first constructor, not its contents. For deep strictness you need deepseq or fully strict element types.
A worked example: shared counters in a request handler
Suppose a handler updates shared stats like this:
-- Run in your application code; no special permissions needed.
-- Requires: containers, text, and the StrictData module above.
import Data.IORef
import Metrics
handleRequest :: IORef ServerStats -> Int -> IO ()
handleRequest ref n = atomicModifyIORef' ref $ \s ->
let s' = s { requestsServed = requestsServed s + 1
, bytesSent = bytesSent s + n }
in (s', ())
Two things work together here. atomicModifyIORef' (the primed, strict variant) forces the new record value itself to WHNF, and StrictData ensures the record's fields are forced when the record is constructed. With lazy fields, even the primed atomicModifyIORef' only forces the outer constructor—the fields inside could still be thunks. This pairing is the standard fix for counter-style space leaks.
To verify the effect rather than assume it, build with optimization and warnings, then take a heap profile:
# Run in your project directory; needs write access for build artifacts.
# -O2 matters: strictness behavior can differ between -O0 and -O2.
cabal build --ghc-options="-Wall -O2 -eventlog"
# Run the binary with RTS options enabled (compile with -rtsopts
# or use cabal's --ghc-options="-rtsopts"):
./your-service +RTS -hc -RTS
This produces a your-service.hp file; convert it with hp2ps or view it with eventlog2html. What you're looking for is a flat residency line under sustained load, compared to a rising staircase before the change. If the line still climbs, your problem is retained data (e.g., an ever-growing map or log buffer), not thunks—and strictness won't help. If you want to see what GHC actually did with your strict fields, -ddump-simpl shows the worker/wrapper split on strict constructors, though the output is verbose.
The trade-offs, honestly
StrictData is module-scoped and indiscriminate. Applied carelessly, it breaks code that depends on laziness: streaming producers, infinite or cyclic structures, and "tying the knot" techniques all rely on deferred evaluation. That's why the pragmatic pattern is to enable it only in modules defining state types—metrics, sessions, caches—while leaving modules that define streaming pipelines lazy by default. The ~ annotation covers per-field exceptions, but if half your fields need tildes, that module probably shouldn't use the pragma.
There are also subtler costs. Strictness can make GHC evaluate work that would never have been needed, and strictness interactions with optimization passes can shift allocation patterns in ways that occasionally hurt CPU-bound code. Behavior can vary across GHC versions and optimization levels, so treat -O2 heap profiles on your GHC version as the source of truth, and check the release notes when upgrading. Finally, remember the WHNF limitation: strict fields prevent thunk chains at the field level, but a strict field containing a lazy structure can still hide a leak inside it.
A practical closing checklist
- Profile first: confirm rising residency with
+RTS -hc -RTSbefore changing anything. - Distinguish thunk leaks (fixed by strictness) from retained data (fixed by bounding the structure).
- Enable
StrictDataper-module on state types; use primed operations likeatomicModifyIORef'andmodifyTVar'alongside it. - Keep streaming and knot-tying modules lazy; use
~for deliberate exceptions. - Re-profile under load at your production optimization level to confirm the flat heap line.
Laziness is one of Haskell's strengths, but production services need predictable memory more than they need deferred counters. Scoped strictness, verified with a heap profile, gives you both.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.