coalescer
Collapse concurrent duplicate async calls into a single execution. Hang one decorator on an async function you already have: while a call is in flight, everyone asking for the same thing joins it instead of starting their own.
from coalescer import coalesce
@coalesce()
async def get_article(article_id: int) -> dict:
return await load_article_from_db(article_id)
That one decorator is the whole difference between these two runs — the SELECT lines the example
prints are trimmed here:
$ python examples/articles.py
without coalescer: 100 concurrent readers of article 1
-> database queries: 100
with @coalesce(): 100 concurrent readers of article 1
-> database queries: 1
All hundred callers get that one result. Different arguments stay separate, so get_article(2) still
runs its own call. No dependencies, fully typed, Python 3.10+.
Install
pip install coalescer
The problem
Duplicate concurrent work is pure waste. N callers want the same thing at the same moment, none of them has it yet, so all N go and do the same expensive thing. Traffic makes it worse instead of better: the busier you are, the more callers fit inside the window where that work is in progress.
nothing is there yet
│
├── caller 1 ──► do the work ──┐
├── caller 2 ──► do the work │ 100 identical calls
├── caller 3 ──► do the work │ for one result
└── ... ──► do the work ─┘
Where this shows up:
- An access token expires. Every in-flight request notices at the same moment and posts to the token endpoint. One refresh was needed.
- A rate-limited upstream. Identical concurrent calls each spend quota to fetch the same answer.
- An expensive resource on first use. Warming a pool, loading a model, reading remote config — concurrent callers should wait for the first one rather than each build their own.
- A cache entry expires. Every request misses at once and piles into the database until the first one writes the value back — a cache stampede, also called a thundering herd or dogpile. A cache cannot fix this by itself; it can only serve what has already been written.
In all four the missing rule is the same: while one call is in progress, everybody else waits for it instead of starting their own. That is request coalescing, and it is all this library does.
Pairing with a cache
coalescer replaces nothing in your stack — it guards the gap a cache cannot cover. The cache
answers hits; @coalesce keeps the misses from multiplying. Any decorator that caches a coroutine
composes the same way:
from coalescer import coalesce
@your_cache(...) # whichever cache decorator you use
@coalesce()
async def get_article(article_id: int) -> dict:
return await load_article_from_db(article_id)
Both orders work, and they are not the same:
| Order | Cache hits | On a miss (100 concurrent callers) |
|---|---|---|
cache outer, @coalesce inner |
Answered straight from the cache, no coordination | 100 cache reads, 1 database query, 100 cache writes |
@coalesce outer, cache inner |
Duplicate hits also collapse into one cache read | 1 cache read, 1 database query, 1 cache write |
Put the cache on the outside when a cache hit must stay as cheap as possible — the common choice. Put
@coalesce on the outside when the cache itself is remote and you would rather not send it a hundred
identical GETs.
Key builders stay compatible: coalescer calls key_builder(func, *args, **kwargs), the convention
cache decorators use, so one callable can be handed to both and they will agree on the key.
Without the decorator
The decorator is a thin layer over a group you can drive yourself:
from coalescer import Coalescer
group = Coalescer()
async def get_article(article_id: int) -> dict:
return await group.do(f"article:{article_id}", load_article_from_db, article_id)
Coalescer has three methods worth knowing:
await group.do(key, fn, *args, **kwargs)— runfn, or join the call already running forkey.group.forget(key)— stop new callers from joining the current flight. The one in progress still serves its own waiters; the next call starts fresh.len(group)— how many flights are in the air.
A decorated function carries its group as an attribute, so you can reach the same controls:
get_article.coalescer.forget("article:1")
Type checkers do not see that attribute — the decorator returns a plain callable so that decorated
methods keep binding self correctly. When you want a typed handle, build the group yourself and
hand it over:
group = Coalescer()
@coalesce(group=group)
async def get_article(article_id: int) -> dict: ...
group.forget(...)
Options
@coalesce(
key=None, # fixed key for every call, ignoring arguments
key_builder=None, # key_builder(func, *args, **kwargs) -> Hashable
noself=False, # leave `self` out of the key, so instances share flights
group=None, # share one Coalescer across several functions
timeout=None, # seconds this caller waits before giving up
)
By default the key is a tuple of the function's module, qualified name, and the arguments
themselves, so arguments are compared by value — two equal objects share a flight even when their
repr() would differ. Arguments that cannot be hashed (a list, a dict, a plain dataclass) fall back
to a repr() of the whole call, which renders them in full; pass a key_builder when they are
large.
Timeouts
Coalescing ties your latency to a call you did not start. You join a flight that began before you
arrived, you cannot see how long it has been running, and you cannot hurry it along — so if the
upstream stalls, everyone who joined stalls with it. timeout bounds that wait:
@coalesce(timeout=2.0)
async def refresh_token() -> str:
return await auth.post("/token")
Three things to know about it:
- The deadline belongs to the caller, not to the flight. Giving up cancels your own wait and nothing else; the work carries on for everybody still waiting. It is cancelled only when the caller who gives up was the last one left.
- It applies to whoever calls, first arrival or not. The caller who happens to start the flight is bound by the same deadline as the ones who join it.
- The clock starts when your call joins, not when the flight took off. A caller arriving four
seconds in with
timeout=2waits until second six.timeoutis not a cap on the work itself — put that inside the function, where your HTTP client or database driver can enforce it.
Giving up raises asyncio.TimeoutError. Catch it by that name: on Python 3.10 it is not the
built-in TimeoutError, so except TimeoutError will not catch it there.
Coalescer.do() has no timeout argument, because its keyword arguments are forwarded to your
function and timeout is a name real functions use. Set a deadline at the call site instead — it
behaves exactly the same way:
await asyncio.wait_for(group.do("article:1", load_article, 1), 2.0)
What it guarantees
Cancelling one caller does not affect the others. The work runs in a task owned by the group, not
in whichever coroutine happened to arrive first, so a request timeout or a disconnected client takes
down only that caller's wait. This is the failure mode that naive implementations get wrong: if the
first caller executes the function inline, its cancellation propagates to everyone waiting behind it.
A timeout is the same story: it cancels the caller who set it, never the shared work while
somebody else is still waiting on it.
Work stops when nobody is left waiting. If every caller is cancelled, the shared task is cancelled too — no orphaned queries running for results no one will read.
Failures are shared, not remembered. An exception reaches every waiter of that flight, then the
key is retired; the next call runs the function again. coalescer never caches anything, including
errors.
It is not a cache. A key exists only while its work is in flight. Two calls that do not overlap in time never share a result — put a cache in front for that.
Two details worth knowing before you deploy it:
- The result object is shared. All waiters get the same object, so mutating it affects everyone. Same as any memory cache.
contextvarscome from the first caller. The shared task inherits the context of whoever started the flight, so a request id or tracing span inside the coalesced call belongs to that caller. Unavoidable when one call serves many.
Coming from Go's singleflight
If you already know golang.org/x/sync/singleflight, here is the mapping:
golang.org/x/sync/singleflight |
coalescer |
|---|---|
Group.Do(key, fn) |
await group.do(key, fn, *args, **kwargs) |
Group.Forget(key) |
group.forget(key) |
Group.DoChan |
not needed — wrap do() in asyncio.wait_for or a TaskGroup |
returns shared bool |
not exposed |
| no cancellation story | cancellation is per-caller, with the shared task refcounted |
Roadmap
- A threaded implementation for synchronous code at
coalescer.sync, sharing the key logic. - An optional Redis backend, so coalescing also holds across processes. The local group always deduplicates in-process first; the backend only decides which process runs the work.
Development
uv sync
uv run pytest
uv run ruff check . && uv run ruff format --check . && uv run mypy
The examples double as documentation, so CI runs them too:
uv run python examples/articles.py
uv run python examples/token_refresh.py
License
MIT
Metadata
Release files for coalescer 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| coalescer-0.1.0.tar.gz | 62.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| coalescer-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 73.6 kB
Release files / coalescer-0.1.0.tar.gz
| Download URL | coalescer-0.1.0.tar.gz |
|---|---|
| Size | 62.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6f1bff3cc801c8935a3e0a599d09336b6eb5ad2128ce8c5af82456d5f08a115b
|
|
BLAKE2b-256 checksum How to use checksums |
22359050fc1a405014bcd38a9be2c7a01abbdd762f3a25677eac694c86145481
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 9, 2026.
Transparency logRelease files / coalescer-0.1.0-py3-none-any.whl
| Download URL | coalescer-0.1.0-py3-none-any.whl |
|---|---|
| Size | 11.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2cd2f292afd282f3bd250534b3c4fa1e4301dc11bb42805913e84f81fe5ca478
|
|
BLAKE2b-256 checksum How to use checksums |
1b1f8556be616f1058aaee29b771862f6883329c4e6d6ecf3624f6f98ae8e74d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 9, 2026.
Transparency log