Haskell World logo Haskell WorldWrite code, build worlds
Systems

Haskell STM for Shared State in Concurrent Services

A payment service I worked on kept a map of open sessions behind a single MVar. At around 400 requests per second it was fine.

Diagram of several Haskell threads committing transactions to shared TVars on a server

A payment service I worked on kept a map of open sessions behind a single MVar. At around 400 requests per second it was fine. At 1,200 it started dropping p99 latency off a cliff, because every handler queued behind one lock just to read a counter. Moving that state to Haskell STM took an afternoon, removed the queue, and the code got shorter.

That is the pitch for software transactional memory in Haskell: shared mutable state that composes, without lock ordering rules written on a whiteboard. It is not magic, and it has failure modes that bite real services. This piece covers where STM earns its place in a concurrent system, how to structure it, and the three mistakes I see most often in production code.

What Haskell STM Actually Gives You

STM lets you group reads and writes to TVars into a transaction that runs with atomically. Either every change in the block becomes visible at once, or none of them do. The runtime tracks which TVars a transaction touched, and if another thread committed a conflicting write in the meantime, your transaction is thrown away and rerun.

The part people underrate is composition. With locks, two correct functions can produce a deadlock when you call them together, because each grabs its locks in a different order. With STM, you can take two functions of type STM and glue them into one transaction, and the result is still atomic. Transferring stock from one warehouse TVar to another becomes a five line function instead of a lock hierarchy document.

Haskell can offer this safely because of the type system. Inside the STM monad you cannot launch missiles: no file writes, no network calls, no logging to stdout. Since a transaction may run several times, that restriction is what keeps retries harmless.

When MVar, IORef or STM Fits Best

Not every piece of shared state needs a transaction. A rough guide from services I have maintained:

ToolGood forWatch out for
IORef with atomicModifyIORef'A single counter or cache updated in one stepNo way to coordinate two refs together
MVarHanding off a value, guarding a resource such as a socketDeadlocks when several MVars are held at once
TVar with STMSeveral related values that must change togetherRetry storms under heavy contention

If your state is one number, an IORef is faster and simpler. STM wins the moment an invariant spans two or more variables, such as "available plus reserved always equals total".

Blocking With retry Instead of Polling

The retry primitive is the feature that makes STM feel different from a database transaction. Calling retry tells the runtime: this transaction cannot proceed yet, so park the thread until one of the TVars I read changes.

A bounded job queue is the classic example. A worker reads the queue TVar, and if it is empty, calls retry. The thread sleeps at zero CPU cost. When a producer writes a job, the runtime wakes exactly the threads whose read set included that TVar. The stm package already ships TBQueue and TMVar built this way, so for most cases you should reach for those rather than writing your own.

Why Small Transactions Beat Clever Ones

Common advice says to wrap a whole request handler in one big atomically block so everything stays consistent. I disagree, and the reason is how optimistic concurrency behaves under load.

A transaction that reads 50 TVars can be invalidated by a write to any of them. The longer it runs, the larger the window for a conflict, and every conflict means rerunning all the work. On a service with 32 capabilities I once watched a reporting transaction that summed a few thousand account balances restart hundreds of times per second while writers kept committing. It never finished during peak hours.

The fixes that worked:

  • Keep each transaction to the minimum set of TVars that share an invariant.
  • Compute expensive pure values outside atomically, then commit the result in a short transaction.
  • For read-mostly snapshots, store an immutable Map inside a single TVar and replace it whole, rather than one TVar per key.
  • Use readTVarIO for reads that do not need to be consistent with anything else, since it skips the transaction log.

Short transactions also make laziness less dangerous. Writing an unevaluated thunk into a TVar is easy, and the next reader pays the cost, sometimes inside its own transaction. Force values with modifyTVar' or a strict data type before committing.

Side Effects and the unsafeIOToSTM Trap

Sooner or later someone wants to log inside a transaction, and discovers unsafeIOToSTM. Resist it. If the transaction retries 40 times, you get 40 log lines for one logical event, and in the worst case an action runs against state that was never committed.

The pattern that holds up is to return a description of what should happen from the transaction, then perform it afterwards in IO. For example, the transaction returns a list of notifications to send, and the surrounding IO code sends them only after atomically has returned. That keeps the effect tied to a committed state change and nothing else.

Measuring STM in a Running Service

STM bugs in production usually look like CPU going up while throughput stays flat. That is the signature of transactions retrying. A few things help you confirm it:

  1. Compare elapsed time against mutator time in the runtime statistics. My notes on reading the GHC +RTS -s report cover what those lines mean.
  2. Wrap hot transactions with a counter incremented in IO before each attempt, and another after commit. A ratio above about 3 to 1 under normal load means contention is real.
  3. Use ThreadScope or eventlog output to see whether capabilities are busy doing useful work or spinning.

Load test with realistic contention. A test that hits random keys will look great, while production traffic concentrates on a handful of hot accounts or sessions. Those hot spots are where retries pile up, and they are also the kind of failure that unit tests never catch.

Where STM Stops Helping

STM coordinates memory inside one process. It does nothing for two replicas behind a load balancer, and it is not a substitute for a database transaction when the state must survive a restart.

A reasonable boundary: use STM for caches, rate limiters, connection pools, in-flight request tracking and work queues. Keep anything a customer would call a record in a proper store, and treat the TVar copy as derived state that can be rebuilt on startup.

FAQ

Is Haskell STM slower than using locks?

For a single uncontended update, yes, by a small constant because of the transaction log. Under moderate contention with several variables involved, STM often comes out ahead because threads do not queue behind one lock and no time is lost to deadlock avoidance.

Can I use STM with async and other concurrency libraries?

Yes. The async package exposes waitSTM and pollSTM, so you can wait on a thread result and a TVar in the same transaction. Most of the concurrency ecosystem is built with STM underneath.

How many TVars is too many?

Thousands of TVars are fine in memory. The problem is how many a single transaction touches. If one transaction reads more than a few dozen under write pressure, restructure the state before you tune anything else.

A Practical Starting Point

Pick the one piece of shared state in your service that is protected by more than one MVar, or by a lock that shows up in latency graphs. Move it into TVars, keep each transaction small, push side effects outside atomically, and add an attempt counter before you ship. If that change goes well, more articles on runtime behaviour live in the systems section.

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