All posts

Dapr: A Portable API Over Non-Portable Guarantees

September 29, 2026Michał Woliński15 min read
Dapr: A Portable API Over Non-Portable Guarantees

Dapr's pitch is portability, and it delivers the part that is hard: one set of building blocks — state, pub/sub, service invocation, bindings, actors, workflow — behind a sidecar, with the underlying technology chosen by configuration rather than by code. Swap Redis for PostgreSQL, or a message broker for another, and your service does not change. The project is mature enough that this is not a bet: it was accepted into the CNCF in 2021 and graduated in October 2024.

What it does not deliver, and never claimed to, is uniformity of guarantees. The API is portable. The semantics underneath are the semantics of whatever store or broker you plugged in, and the same request can mean different things — or quietly mean nothing — depending which component is behind it.

That is worth more than a caveat, because Dapr's own documentation states it plainly: not all stores are created equal, and you should query each store's capabilities and make your code adaptive. The design assumes you will ask. This article is about what happens when you do not, with a runnable demo: two state stores that Dapr lists as Stable, the same requests sent to both, and the exact responses each one gives.

The short version: one of the two differences fails loudly and the other fails silently, and the silent one is the one that protects your data.

The promise, and the fine print

Dapr's state management API gives you the features that are normally painful to build yourself: concurrency control, consistency selection, bulk and transactional multi-key writes, TTL, and a query interface. A component descriptor decides which store implements them, and the docs are explicit about the defaults you should assume before you configure anything: eventual consistency and last-write-wins.

Two further details from the same page are the thesis of this article in the documentation's own words. First, optimistic concurrency is enforced through ETags, and if your application omits the ETag, Dapr skips the check. The guard is opt-in per request. Second, for stores that have no native ETag concept, the component is expected to simulate one — which is a reasonable promise and also a promise that can be imperfect, as the last section of this article shows.

And then the sentence that should be printed on every Dapr deployment: not all stores are created equal. The capability matrix makes the scale of that concrete. Of the two dozen or so stores listed, the great majority support CRUD and nothing else. Transactions, ETags, TTL, actor support and workflow support are each a column, and the pattern of ticks is not what most people assume: GCP Firestore — a stable, managed, widely used database — has a tick in exactly one column.

The same request, against two Stable stores

To keep this concrete I wired the smallest possible stack: daprd running standalone in a container, two state store components, and one command that sends identical requests to both. No cluster, no Dapr CLI. The whole thing is in a repository you can clone — compose file, components, subscriber, probe script and the captured run — and the versions are pinned in it: daprd 1.18.4 (1.18.5 was still an RC when I ran this), Redis 8.10.2, Memcached 1.6.45.

# components/statestore-redis.yaml apiVersion: dapr.io/v1alpha1 kind: Component metadata: name: statestore-redis spec: type: state.redis version: v1 metadata: - name: redisHost value: redis:6379 - name: redisPassword value: "" - name: actorStateStore value: "false"
# components/statestore-memcached.yaml apiVersion: dapr.io/v1alpha1 kind: Component metadata: name: statestore-memcached spec: type: state.memcached version: v1 metadata: - name: hosts value: memcached:11211 - name: maxIdleConnections value: "10"

Both are certified Stable by the project. The first thing worth doing is asking the runtime what each one can actually do, because it will tell you:

statestore-memcached ['TTL'] statestore-redis ['ETAG', 'TRANSACTIONAL', 'TTL', 'QUERY_API', 'KEYS_LIKE', 'ACTOR']

That is GET /v1.0/metadata reporting capabilities per component, and it is the single most useful call in this article. Memcached advertises exactly one capability. The API in front of it is identical to the API in front of Redis, and the API in front of it has no ETags, no transactions, and no query interface.

A single-key write does not care. The same request to both stores returns 204, because CRUD is the one column every store has ticked.

A multi-key transaction does care, and it says so:

redis POST /v1.0/state/statestore-redis/transaction -> 204 memcached POST /v1.0/state/statestore-memcached/transaction -> 500 {"errorCode":"ERR_STATE_STORE_NOT_SUPPORTED", "message":"state store statestore-memcached doesn't support transactions"}

This is the good failure. It is loud, it is specific, it names the store, and it happens on the first call rather than the thousandth. If every capability gap behaved like this, the rest of this article would be unnecessary.

The guarantee that fails silently

The ETag path does not behave like that. On Redis, reading a key returns its version in an Etag header — Dapr stores state as a Redis hash of data and version, and the version number is the ETag. A conditional write with the matching version succeeds, and the same ETag used a second time is rejected:

redis: write v1 -> 204 redis: Etag header on read -> 2 redis: write, matching Etag + first-write -> 204 redis: write, same Etag again (stale) -> 409 {"errorCode":"ERR_STATE_SAVE", "message":"...possible etag mismatch..."} redis: value after the stale write -> 200 "v2"

Note what the last line means: the losing write did not merely fail, it left the value untouched. That is optimistic concurrency doing its job, and it took the project an issue to get there — dapr/dapr#2619 was filed because an ETag mismatch used to return 500 rather than 409, which made a conflict look like a server fault.

Now the same sequence against Memcached:

memcached: write v1 -> 204 memcached: Etag header on read -> <none> memcached: write, matching Etag + first-write -> 204 memcached: write, deliberately stale Etag -> 204 memcached: value now -> 200 "v3"

There is no Etag header on the read, because this component does not surface one — and that turns out to be a statement about the component rather than about the engine underneath it. Memcached's own protocol carries a CAS token: gets returns a 64-bit unique value for the key, and cas writes only if that value still matches — the same compare-and-swap the ETag path performs. The Dapr state.memcached component does not map that token onto the ETag the API expects, so the guard has nothing to compare against. Which is worth carrying into every row of that matrix: a missing tick is a claim about somebody's component, with a date on it, not a law about the database. And the write carrying a deliberately stale ETag — a value that cannot possibly match anything — returns 204 and overwrites the key anyway.

Read that again as a developer: the request you send to ask for protection is accepted, the response says the write succeeded, and no protection happened. The parameter is ignored rather than rejected. Nothing in the response distinguishes "your precondition was checked and held" from "your precondition was not checked at all".

This is the asymmetry that matters more than any individual capability gap. Transactions announce their absence at the call site. ETags do not, and their absence is the one that lets concurrent writers clobber each other without a single error in your logs. If you are going to remember one thing from this article, make it this: a capability you did not verify is a guarantee you do not have, and some of them fail quietly.

A Redis foot-gun between the two paths

While checking the above I found something that is not in the documentation and is worth reporting carefully, because it sits exactly on the boundary this article is about: the guarded and unguarded write paths are not independent.

Write a key with no concurrency option, and Dapr stores two fields:

data "v1" version 1

Now write it once with "options": {"concurrency": "first-write"} — correctly, with the ETag you just read, so the write succeeds and returns 204:

data "v2" version 2 first-write 0

The successful conditional write left a third field behind, named after the option that was used. And from that point on, writes to that key that omit the ETag stop working:

now write v3 with NO Etag -> 500 {"errorCode":"ERR_STATE_SAVE", "message":"...failed to set key demo||probe-marker: ERR user_script:14..."} and again, NO Etag -> 500 value is still (correctly) v2

Two things make this worth a section. The failure is permanent for that key — repeating the write does not clear it — and it silently preserves the old value, so a retry loop with no error handling looks like a slow success. Writes that supply the ETag they just read continue to work, which means the practical rule is blunt: do not mix guarded and unguarded writes on the same key. If you use first-write, use it for every write to that key, always with a freshly read ETag, or delete and recreate the key.

Framing check, because this is a single reproduction on one version: I observed it on dapr 1.18.4 with the steps above, using the Redis state store, and the leftover field is named exactly after the concurrency option, which is what suggests the mechanism. Two neighbouring reports exist — components-contrib#1010, which describes the Redis store overwriting a value when first-write is used with no ETag, and #2619 for the status code history — so this is a known-fragile area of the component rather than an exotic corner. Treat it as a reason to pin a version and probe its behaviour, not as a claim that the store is broken.

Advertised is not the same as configured

The capability list is a statement about what a store can do, not about what your deployment has set up. Redis advertises QUERY_API, so I queried it:

redis POST /v1.0-alpha1/state/statestore-redis/query -> 500 {"errorCode":"ERR_STATE_QUERY", "message":"...query index not found"} memcached POST /v1.0-alpha1/state/statestore-memcached/query -> 500 {"errorCode":"ERR_STATE_STORE_NOT_SUPPORTED", "message":"state store does not support querying"}

Two different failures wearing the same status code. Memcached genuinely cannot query. Redis can, and refuses because no index has been configured — queryIndexes in the component metadata plus the index itself in Redis. The capability is real and the feature is not available until you build it, which is a distinction that costs an afternoon if you plan against the capability list rather than against your actual configuration.

The actor and workflow building blocks sit in the same category, with a sharper rule. A store is eligible to hold actor state only if it supports both transactions and ETags, only one store can be designated for actors, and a distributed backing store has to provide strong consistency. Cross-reference the matrix and the consequence is immediate: Memcached, Cassandra, Firestore, Azure Blob and Table Storage cannot be your actor store at all — and neither can any CRUD-only store, however production-ready the project considers it.

Pub/sub moves the problem to your handler

Pub/sub has the same shape, one layer further out. The API is identical across brokers, the delivery semantics are at-least-once, and ordering is not guaranteed — so the duplicate is not an edge case you handle later, it is the contract.

I published one event to a Redis-backed topic with a subscriber that fails its first two deliveries on purpose:

publish one event -> 204 2026-09-29T19:12:03Z delivery #1 of 028b6c45-... -> 500 (deliberate) 2026-09-29T19:12:04Z delivery #2 of 028b6c45-... -> 500 (deliberate) 2026-09-29T19:12:05Z delivery #3 of 028b6c45-... -> 200 (acked)

The same event id, three times, one second apart. Two consequences follow, and both are yours to implement rather than Dapr's to provide. Your handler must be idempotent keyed on the CloudEvent id — which is the id on the wire, not one you chose. And the retry interval you just saw came from the resiliency policy I configured in the demo, not from the API: without an explicit policy, redelivery behaviour is whatever the component does by default, which is another guarantee that belongs to the component. The patterns for the handler side are the ones this blog already covers — the inbox pattern is exactly the dedup table this section is asking you to build.

The outbox needs a store that can do transactions

Dapr supports transactional outbox messaging, which is one of its genuinely attractive features: it gives you a single atomic operation spanning a state write and a message publish, which is normally the hardest part of an event-driven system to get right. Here is the sentence that matters, from the outbox documentation: it achieves a single transaction across a transactional state store and any message broker.

Read that as a consequence of everything above. Enable the outbox on a store that cannot do transactions — Memcached, Cassandra, Firestore, or any of the CRUD-only stores — and the pattern you asked for does not exist. You have not misconfigured it; you have asked a component to do something outside its column in the matrix. The outbox pattern is the pattern; whether your store can implement it atomically is a property of the store, and the gap between the two is where duplicate messages and lost writes live.

Write code that asks instead of assuming

Dapr's own remedy is to query the capability metadata and adapt. In practice that means a startup check, not a comment, because the alternative is discovering the gap on the first multi-key write in production. And the check has to be declared per store rather than once for the service. A store you use as a cache and a store you use as a system of record do not need the same guarantees; a check that demands the same of both will fail an architecture that is perfectly correct, and a check that fails for the wrong reason gets deleted.

#!/usr/bin/env python3 """Ask the sidecar what each state store can actually do, then decide. Requirements are declared PER STORE, because how a store is used decides what it must guarantee: a store used as a cache and a store used as a system of record do not need the same columns. An undeclared store also fails the check, because a configured store nobody wrote a requirement for is a use nobody agreed to. python3 probe_capabilities.py # per-store requirements UNIFORM=1 python3 probe_capabilities.py # the uniformity mistake, made explicit """ import json import os import sys import urllib.request DAPR = "http://localhost:3500" # What THIS service asks of each store it uses. REQUIRED_BY_STORE = { "statestore-redis": {"TRANSACTIONAL", "ETAG", "QUERY_API"}, "statestore-memcached": {"TTL"}, } if os.environ.get("UNIFORM"): # Assume every configured store needs every guarantee. This is the mistake # the article is about, and it is one line away - which is the point. union = set().union(*REQUIRED_BY_STORE.values()) REQUIRED_BY_STORE = {name: set(union) for name in REQUIRED_BY_STORE} def state_store_capabilities() -> dict[str, set[str]]: with urllib.request.urlopen(f"{DAPR}/v1.0/metadata", timeout=5) as response: metadata = json.load(response) stores: dict[str, set[str]] = {} for component in metadata["components"]: if not component["type"].startswith("state."): continue stores[component["name"]] = set(component.get("capabilities", [])) return stores def main() -> int: stores = state_store_capabilities() failed = False for name, capabilities in sorted(stores.items()): required = REQUIRED_BY_STORE.get(name) if required is None: # Configured but never declared: fail rather than guess. print(f"{name}: advertised {sorted(capabilities) or ['(nothing)']}") print(" configured but not declared in REQUIRED_BY_STORE") failed = True continue missing = required - capabilities print(f"{name}: advertised {sorted(capabilities) or ['(nothing)']}") print(f" needs {sorted(required)}") print(f" {'MISSING ' + str(sorted(missing)) if missing else 'ok'}") failed = failed or bool(missing) if failed: print("\nrefusing to start: a store cannot meet the requirements declared for it") return 2 print("\nevery configured store meets the requirements declared for it") return 0 if __name__ == "__main__": raise SystemExit(main())

Against the demo stack, with the requirements declared per store, it passes:

statestore-memcached: advertised ['TTL'] needs ['TTL'] ok statestore-redis: advertised ['ACTOR', 'ETAG', 'KEYS_LIKE', 'QUERY_API', 'TRANSACTIONAL', 'TTL'] needs ['ETAG', 'QUERY_API', 'TRANSACTIONAL'] ok every configured store meets the requirements declared for it

And the same script with one environment variable set — collapsing the per-store requirements into a single set demanded of everything — reproduces exactly the assumption this article is about:

statestore-memcached: advertised ['TTL'] needs ['ETAG', 'QUERY_API', 'TRANSACTIONAL', 'TTL'] MISSING ['ETAG', 'QUERY_API', 'TRANSACTIONAL'] statestore-redis: advertised ['ACTOR', 'ETAG', 'KEYS_LIKE', 'QUERY_API', 'TRANSACTIONAL', 'TTL'] needs ['ETAG', 'QUERY_API', 'TRANSACTIONAL', 'TTL'] ok refusing to start: a store cannot meet the requirements declared for it

Two design choices in that script are the article compressed, and one of them is a correction I had to make to my own first version. The requirements are declared per store, next to the code that uses that store — not assumed from the component name, and not flattened into one service-wide set, which is the second run above: it refuses to start on an architecture that is perfectly correct. That flattening is the tempting simplification, and it is the same error as demanding uniform guarantees from the API itself. The other choice is that failure means refusing to start, because a capability gap that surfaces at boot is an incident avoided, while the same gap surfacing under load is an incident.

What the probe cannot check is the Redis foot-gun from section 5, or the difference between an advertised capability and a configured one. Capability probing raises the floor; it does not replace testing the behaviour you depend on against the version you pinned.

Where that check runs matters as much as what it checks, and a startup probe is the backstop rather than the first line: by the time the sidecar boots in production, the deploy has already happened. The cheaper place to catch this class of mistake is CI, with the real component descriptors and a real sidecar — which is exactly what the demo for this article is, so the same two commands work as a job.

# step excerpt for your CI (e.g. .github/workflows/capabilities.yml) - name: dapr component capabilities run: | docker compose up -d python3 probe_capabilities.py docker compose down -v

What CI cannot do is read the requirements out of the manifest, and it is worth being precise about why: capability lists are reported by the component implementation at runtime, not declared in the descriptor. A descriptor lint still catches the cheap half of the problem — a component type that is not the one the service was validated against, a metadata key that went missing, a store swapped without anyone noticing — but the authoritative answer only arrives by booting the sidecar with the real descriptors and asking. Static checking raises the floor; the runtime probe is what makes the floor true. That ordering is also why the boot check should refuse to start rather than log a warning: it is the last opportunity to fail before traffic arrives.

When the sidecar earns its place

None of the above is an argument against Dapr. Every one of these differences is inherent to the problem it solves: if the same API has to sit in front of Redis and Firestore, then either the guarantees are the weakest common denominator or they are conditional on the component. Dapr chose the second, documented it, and gave you a way to ask. That is the honest design.

What follows is that the value is conditional too. Dapr pays for its sidecar when:

  • You are genuinely polyglot. The portability is across languages as well as backends, and a sidecar gives a PHP service and a Go service the same contracts without reimplementing them.
  • You move between backends or clouds. The value is the option, not the current state: swapping a broker without touching application code is worth a lot at migration time and nothing on the day you do not migrate.
  • You want the building blocks you would otherwise build badly. Service invocation with mTLS, pub/sub with resilient subscriptions, actors, and workflow are each weeks of work done adequately.
  • Someone owns the platform. A sidecar per pod, a control plane, component versions and upgrade cadence are real operational surface. Without an owner, it becomes drift.

Failure modes

  • Assuming capability uniformity. The single most expensive mistake, because the API looks the same and the promises are not.
  • Guarding on one store and not the other. The classic path: optimistic concurrency works in staging on Redis and is silently absent in production on the store that had the cost profile you wanted.
  • Mixing guarded and unguarded writes on one key. Section 5's finding; always send the ETag you just read.
  • Planning against the capability list instead of the configuration. QUERY_API advertised, index absent, feature unavailable.
  • Bypassing Dapr for reads. Querying the underlying store directly is fine for read-only reporting, and it is outside Dapr's concurrency control by definition — so writes must still go through the API or the version numbering is a lie.
  • Swapping a component mid-flight. Changing a state store behind a running service changes the guarantees of code that did not change. The configuration diff is one line; the semantic diff can be a transaction that no longer exists.
  • Treating the sidecar as the guarantee. Dapr is the delivery mechanism for these features, not the enforcement of them. The enforcement lives in the component, and the responsibility for knowing which component you are on lives with you.

When this does not fit

The honest counterpart: if you are one language, one cloud, one state store, and you need key-value reads and writes, Dapr is an indirection with a control plane attached. A client library for your database, plus the pub/sub client you already have, is fewer moving parts, easier to debug, and has no version skew between a sidecar and an SDK.

Two specific cases where I would not reach for it. When the portability is never exercised — if no one will swap the store or the broker, you are paying an abstraction tax on a capability you do not use, and you still own the capability differences this article described, just with more components. And when the guarantee you need is the one the abstraction hides: if your correctness argument depends on transactional writes or on conditional updates, the sidecar adds a layer between you and the thing you actually have to reason about, and the guarantee is enforced by the component either way.

What remains true in both cases is the rule underneath all of this, and it costs nothing to apply even without Dapr: know which of your dependencies enforce which preconditions, and check rather than assume. The abstraction does not decide that for you. It only makes it easier to forget.

Summary

Dapr's building blocks are portable, and its guarantees are a property of the component behind them. The documentation says so — not all stores are created equal, query the capabilities, assume eventual consistency and last-write-wins by default — and the capability matrix quantifies it: most stores support CRUD and nothing else, and even managed, stable databases have a single tick.

Two stores certified Stable, the same requests: single-key writes succeed on both; a multi-key transaction returns 204 on one and a clear 500 ERR_STATE_STORE_NOT_SUPPORTED on the other; a conditional write is enforced on one and silently ignored on the other, returning success while overwriting the value. That asymmetry is the finding worth keeping: transactions announce their absence, ETags do not.

On top of that sit the details a capability list cannot tell you — QUERY_API advertised but unconfigured, the outbox requiring a transactional store, actors requiring transactions and ETags, and one reproducible Redis behaviour on 1.18.4 where a successful first-write leaves a marker that breaks subsequent unguarded writes to the same key.

None of that is a reason to avoid the project. It is a reason to ask it questions before you deploy: probe each store's capabilities at boot against the requirements you declare for that specific use, refuse to start when one is missing, always send the ETag you just read, and test the behaviour you depend on against the version you pinned rather than against the API's promise.