· 4 min read

The thunk should be the fallback

Default laziness becomes plausible when compilers evaluate demanded work eagerly, represent common delays as inspectable data, and reserve closure thunks for the cases they cannot understand.

A yellow pneumatic tube carrier lies on a white surface, sealed at both ends with dark caps.
The Central Intelligence Agency, Public domain

A thunk is a sealed capsule of work with a heap address. You can pass it through half the program and keep everything in its environment alive. Its exception may surface in a caller that never saw the original expression, while the profiler labels the object THUNK and considers the description complete.

I still think laziness can become a sane default again. The source semantics are too useful to discard: unused work disappears, and recursive structures can be consumed incrementally. The viable version lets the compiler choose a cheaper representation instead of turning every delay into an anonymous closure.

The capsule keeps its luggage#

Call-by-need stores postponed work in a heap object, evaluates it at first demand, and memoizes the result. That behavior makes this pipeline attractive: asking for ten rendered items can parse and filter only enough input to produce ten results.

Preview.hs
preview file =  take 10    (map render      (filter visible (parse file)))

The pleasant source hides a retention question. If the unevaluated tail closes over the original buffer, keeping the first result can keep the whole file. Why is one rendered line keeping the whole file alive?

result: Conshead: rendered linetailtail: Closurecode: continue pipelineenvinput: ByteStringpayload: whole file

A lazy list tail retains its source buffer through a closure environment.

  • result: Cons: head: rendered line; tail points to tail: Closure
  • tail: Closure: code: continue pipeline; env points to input: ByteString
  • input: ByteString: payload: whole file
The unevaluated tail points through its closure environment to the input buffer after the first rendered line exists.

GHC's profiling guide explains that a thunk captures the current cost-centre stack and restores it when evaluation finally happens. That helps assign the bill, but the runtime object remains generic. A delayed parser step and a queue rotation both arrive in the heap as closures whose meaning has to be reconstructed from code and retained values.

Today you patch the demand by hand#

Haskell programmers already know where the abstraction tears. A lazy foldl can build a chain of pending additions, so performance-sensitive code reaches for foldl', bang patterns, strict fields, or an entire module compiled with Strict. The current GHC strictness documentation says removing thunks from an inner loop can be a huge win.

GHC also spends serious effort proving when it can evaluate early. Demand analysis, worker/wrapper, unboxing, and newer speculative evaluation passes remove delays that the source language requested by default. The optimization guide records cases where more strictness increases allocation or runtime, which is why a global switch remains too blunt.

I don't think today's compiler and tooling make default laziness comfortable for ordinary application code. You can usually repair the hot loop, but the first warning may be a heap profile taken after residency grows under a workload nobody put in the benchmark.

Give deferred work a name#

The interesting recent move changes the runtime object. The 2025 First-Order Laziness paper replaces many closure thunks inside lazy data structures with declared lazy constructors. An append operation can remain suspended as SAppend(left, right) rather than becoming an unknown function plus an environment.

stream.kk
type stream<a>  SCons(head: a, tail: stream<a>)  SNil  lazy SAppend(left: stream<a>, right: stream<a>) ->    match left    SCons(x, xs) -> SCons(x, SAppend(xs, right))    SNil -> right

That representation gives the compiler facts a closure erased: the node's size, its evaluator, the fields that remain live, and the update path. The implementation can inspect unfinished structures during debugging, reuse nodes in place, avoid some indirections, and specialize evaluation to constant stack space. In the paper's Koka benchmarks, lazy constructors beat the equivalent traditional thunks across the tested queues and heaps and approached strict versions.

The limitation is useful too: constructors must be declared for the delayed operations a data type supports. Arbitrary higher-order suspension still needs a closure fallback. Most persistent queues and streams use a small vocabulary of deferred operations, though, so the common path can be legible without forbidding the general one.

A default with several gears#

A plausible lazy language keeps Haskell's source-level promise while refusing one universal implementation. Proven demand evaluates now. Known delayed data operations become first-order constructors. The remaining unknown computation gets the old closure thunk, and strict modules stay available for numeric or systems work.

Universal thunkLayered laziness
RepresentationA closure carrying code and an environmentProven demand evaluates now, while known delays use constructors
DebuggingHeap tools can see a THUNK and whatever it retainsTools can print the operation and its fields
CoverageWorks for arbitrary higher-order delayed computationUse a closure only when the compiler has no first-order form
CostAllocation, indirection, and delayed failure remain commonRequires stronger analysis and a richer runtime representation
The layered design keeps unrestricted delay while moving common cases into representations the compiler can inspect.

Reasoning tools are catching up with the representation work. The 2024 bidirectional demand semantics derives costs from what a lazy result is demanded to produce and supports mechanized proofs for persistent queues. Production predictability remains open. The work gives compilers and libraries something better than folklore for explaining where evaluation goes.

The experiment I want ends in the debugger, before the benchmark chart. Force one element of a lazy append and inspect the rest. If the tool prints SAppend(SCons(...), ...), you can see the pending operation and what it owns. If the same heap view still offers a gray band named THUNK, I am still reaching for a bang pattern.