Device Shadow vs Device Twin: AWS IoT Device Shadow, Azure IoT Hub Device Twins and Eclipse Ditto Compared (2026)

Device Shadow vs Device Twin: AWS IoT Device Shadow, Azure IoT Hub Device Twins and Eclipse Ditto Compared (2026)

Device Shadow vs Device Twin: AWS IoT Device Shadow, Azure IoT Hub Device Twins and Eclipse Ditto Compared

A thermostat goes offline for six hours. During that window an operator changes its setpoint twice, a fleet job pushes a new reporting interval, and a support engineer rolls the setpoint back. When the device reconnects, which of those changes should it apply, and how does it know? That question, not the marketing label, is what the device shadow vs device twin debate is really about.

It matters now because the three most common answers have diverged. AWS IoT Device Shadow computes a delta for you. Azure IoT Hub device twins hand you versioned patches and drop notifications for offline devices. Eclipse Ditto stores desired properties and enforces conditional writes, but leaves reconciliation entirely to you. Meanwhile Microsoft has published a 2029 retirement date for Azure IoT Central, pushing more teams onto raw IoT Hub twins.

You will leave with a precise, source-checked comparison of limits, topics and version semantics, a reconnect-safe reconciliation pattern that works on all three, worked capacity numbers, and a decision path for when to build your own.

What this covers: the shared desired/reported model, each platform’s contract in detail, a comparison table, reconnect storms and cost math, failure modes, and a build-versus-buy checklist.

Context and Background

“Device shadow” and “device twin” name the same idea: a cloud-side JSON document that holds what a device last said about itself (reported state) and what someone wants it to become (desired state). AWS popularised the shadow term with AWS IoT Core. Microsoft used “device twin” for Azure IoT Hub. The Eclipse Foundation’s Ditto project calls its version a “thing” with “features”. The word twin in this sense is much narrower than a physics-based or process digital twin, a distinction our explainer on IoT versus digital twins unpacks in depth.

The pattern exists because devices are intermittently connected and slow. A backend cannot block on a battery-powered sensor that wakes every fifteen minutes. So both sides talk to a durable intermediary instead. The backend writes intent; the device reads intent when it can, acts, and writes back the facts. Any reader, from a dashboard to a rules engine, can query the last known truth without touching the device.

What the pattern does not do is make devices consistent. It moves the consistency problem into a protocol between three parties, each with its own clock, retry logic and failure modes. Each platform defines a different slice of that protocol. AWS defines who computes the difference between desired and reported. Azure defines how versions increase and when notifications are lost. Ditto defines who may read or write which path, and under what preconditions.

The incumbent landscape in late 2026 looks like this. AWS IoT Core Device Shadow supports one unnamed “classic” shadow and multiple named shadows per thing. Azure IoT Hub device twins come with module twins for edge workloads, and twin queries across the fleet. Eclipse Ditto reached release 3.9.0 on 13 May 2026, adding namespace-scoped policies and a W3C Web of Things Thing Directory endpoint. Standards-based alternatives exist too, notably OMA LwM2M, whose object model gives you a typed device-management contract instead of free-form JSON; see our LwM2M device management architecture post.

For the canonical definitions, the AWS Device Shadow service documentation and the Azure IoT Hub device twin guide are the primary sources this comparison is checked against.

Device Shadow vs Device Twin: The Shared Reconciliation Contract

A device shadow and a device twin are the same pattern: a cloud JSON document with desired state written by applications and reported state written by the device. They differ in contract. AWS computes a delta and supports version-checked updates; Azure versions each section and drops offline notifications; Ditto stores desired properties and enforces policies but never reconciles.

Device shadow vs device twin reconciliation loop between backend, twin store and device agent

Figure 1: The shared device shadow vs device twin reconciliation loop: intent flows in as desired state, facts flow back as reported state, and the device agent is the only component that closes the gap.

Figure 1 shows the one architecture every option implements. A backend application or operator writes desired state into a twin store. The store emits either a computed delta or a raw patch to the device agent. The agent compares intent against the hardware, actuates, observes the result and writes reported state. Change events fan out to rules and routing, and fleet queries read the store rather than the device. Versions and entity tags flow back to the writer so it can detect conflicting updates.

The twin store is a mailbox, not a controller

The most common design error is assuming the platform “pushes configuration to devices”. None of the three does that in a controller sense. They persist intent and notify a connected subscriber. If the device is offline, crashes mid-apply, or rejects the value, the store does not retry or roll back. Desired state simply sits there.

This is why the device agent, the small piece of firmware or edge software that consumes desired state, is the real controller. It must be idempotent, because it will see the same intent more than once. It must be level-triggered rather than edge-triggered, meaning it acts on “what should be true now” instead of “what changed”. Kubernetes controllers follow the same discipline for the same reasons.

Level-triggered design has a concrete consequence. On every reconnect, the agent should fetch the full desired document and converge to it, rather than replaying a history of patches. Azure’s own guidance says exactly this: subscribe to desired-property notifications, then retrieve the full document, then ignore any notification whose version is not newer. AWS’s delta topic and Ditto’s event stream are conveniences on top of the same rule.

Reported state is evidence, not an echo

A second error is having the device copy desired values into reported values as an acknowledgement. That tells the backend the message arrived, not that the hardware complied. If a motor controller clamps a requested speed of 3,200 rpm to 3,000 rpm, reported state must say 3,000.

Good reported documents therefore carry three kinds of fields. Observed values come from sensors or hardware registers. Applied configuration records what the agent actually set, plus the desired version it was derived from. Status fields describe the apply attempt itself, such as pending, applied, rejected or failed with a reason. Azure’s documentation models this explicitly with a status alongside the configured value in its examples.

Carrying the desired version into reported state is the cheapest correctness tool available. With it, any reader can compute convergence as a simple comparison: has the device applied version N or later? Without it, you must diff values, which breaks when the device legitimately clamps or transforms them.

Versions, not timestamps, order the world

All three platforms expose monotonic version numbers, and all three discourage relying on wall-clock time for ordering. AWS increments a single document version on every update and lets writers include an expected version. Azure keeps separate $version counters for desired and reported sections, plus an ETag for the whole twin. Ditto exposes a revision number per thing and strong ETags for conditional requests.

Device clocks drift, and backend clocks disagree by milliseconds across regions. A version counter assigned by the store is the only order every party can agree on. Use it for three jobs: rejecting stale notifications on the device, detecting lost updates on the backend, and labelling reported state with the intent it satisfied. Timestamps remain useful for staleness alarms, such as flagging a twin not reported in 24 hours, but never for deciding which write wins.

The rest of this post compares how each platform implements these three ideas, where each one leaves gaps, and what that means for the code you will write.

AWS IoT Device Shadow in Detail

AWS IoT Device Shadow is the most opinionated of the three, because the service computes the difference between desired and reported state for you. That one feature shapes everything about how AWS device agents are written.

Classic and named shadows

Every AWS IoT thing can have one unnamed shadow, called the classic shadow, and any number of named shadows. Topics differ only by prefix. The classic shadow lives under $aws/things/thingName/shadow, and a named shadow under $aws/things/thingName/shadow/name/shadowName. Shadow names are limited to 64 bytes of UTF-8, and thing names to 128 bytes, according to the AWS IoT Core service quotas page.

Named shadows exist mainly to work around the document size quota and to separate concerns. The quota table lists a maximum of 8 KB per shadow document, adjustable through a quota request, and notes that metadata does not count toward size for quota or pricing purposes. JSON nesting is capped at 8 levels inside the desired or reported section, and that cap is not adjustable. Note that the separate Device Shadow error-message reference still quotes a maximum of 6 nesting levels, so design for 6 if you want to be safe against either reading. A device with firmware configuration, network settings and application parameters fits more comfortably in three named shadows than in one crowded classic shadow.

Splitting also limits blast radius. A dashboard team that writes display preferences into a ui named shadow cannot accidentally collide with an OTA pipeline that writes into a firmware named shadow. Each shadow has its own version counter, so a conflict on one does not force a resync of the other. Policies can grant publish rights per named-shadow topic, which gives you path-level authorisation that a single document cannot.

The topic contract

Devices publish to three request topics and subscribe to response topics. Publishing an empty message to /get returns the document on /get/accepted or an error on /get/rejected. Publishing a partial state document to /update returns /update/accepted or /update/rejected. Publishing to /delete removes the shadow, though AWS notes that deleting a shadow does not reset its version number to zero.

Two topics carry the fan-out. /update/delta is published only when an accepted update leaves desired and reported different, and it contains only the differing desired attributes. /update/documents is published after every successful update and carries the previous and current documents, which is what backend services usually consume. AWS explicitly advises against wildcard subscriptions such as shadow/#, because new shadow topics may be added and would then match unexpectedly.

How the delta is computed

The delta algorithm is simple and worth knowing precisely. The service walks every field in desired and compares it to reported. Fields present in desired but missing from reported appear in the delta. Fields present only in reported never appear. For nested objects, only the differing path is included, so a colour object with one changed channel yields a delta with just that channel.

Arrays are the exception. AWS treats arrays as atomic values, so any difference copies the whole desired array into the delta, and partial array updates are not supported. Arrays also cannot contain null elements. If your configuration includes lists, such as allowed BLE peer addresses, model them as objects keyed by identifier instead. That keeps deltas small and lets you remove single entries by setting them to null.

Null is the deletion operator. Setting a desired field to null removes it, and setting the whole desired section to null clears all intent. That is the clean way to acknowledge completion of a one-shot command stored in a shadow, although a dedicated jobs or commands mechanism is usually a better fit for commands than state.

Version checks and conflicts

Every update increments the document version by one. A writer can include a version field in an update. If it matches the current version, the update proceeds; if not, the update is rejected and the writer must fetch the latest document before retrying. AWS documents version conflicts as HTTP status 409 in its Device Shadow error reference. Omitting version bypasses the check entirely, which is convenient for devices and dangerous for multi-writer backends.

AWS IoT Device Shadow version conflict and delta sequence in a device shadow vs device twin comparison

Figure 2: AWS IoT Device Shadow update flow. The device receives a delta after a desired change, reports the applied value, and a stale backend write is rejected with a version conflict.

Figure 2 traces the normal flow and the conflict path. The backend sets a fan speed with an expected version, the service accepts and bumps the version, and the device receives a delta. After the device reports, the version advances again. A second backend writer that read the shadow before the device reported now sends a stale version and is rejected. It must re-read and decide whether its intent is still valid.

The clientToken field, limited to 64 bytes, lets a device match responses to its own requests on shared response topics. Use a monotonic counter or a short random string, and treat any accepted message without your token as someone else’s update.

Rate limits that bite in practice

The quota table sets 20 requests per second per thing across Get, Update and Delete over both MQTT and HTTP, adjustable on request. It also caps in-flight unacknowledged shadow messages at 10 per thing on a single connection, and rejects further requests with HTTP 429 until that drops. The account-wide default is 4,000 Device Shadow API requests per second, or 400 in some Regions, also adjustable.

The per-thing limits matter for gateways. A Modbus or BLE gateway that proxies 50 downstream sensors as named shadows under one thing shares that thing’s budget. If it writes reported state for all 50 on a single scan cycle, it will hit the in-flight cap. Either model each downstream sensor as its own thing, or batch values into fewer shadows.

A minimal AWS device agent loop, using Python and Paho MQTT, looks like this. It is deliberately level-triggered: it converges on the full desired document after reconnecting, and uses deltas only as a wake-up signal.

import json, uuid
import paho.mqtt.client as mqtt

THING = "thermostat-0042"
PFX = f"$aws/things/{THING}/shadow/name/config"
applied_version = 0

def apply(desired: dict) -> dict:
    # Clamp and apply to hardware; return what was actually set.
    sp = max(10.0, min(30.0, float(desired.get("setpoint", 21.0))))
    hw_set_setpoint(sp)
    return {"setpoint": sp}

def on_connect(c, u, flags, rc, props=None):
    for t in ("/get/accepted", "/get/rejected", "/update/delta",
              "/update/accepted", "/update/rejected"):
        c.subscribe(PFX + t, qos=1)
    c.publish(PFX + "/get", "", qos=1)          # full resync on every connect

def on_message(c, u, msg):
    global applied_version
    doc = json.loads(msg.payload or b"{}")
    ver = doc.get("version", 0)
    if msg.topic.endswith("/get/accepted"):
        desired = doc.get("state", {}).get("desired", {})
    elif msg.topic.endswith("/update/delta"):
        if ver <= applied_version:
            return                               # stale or duplicate
        desired = doc.get("state", {})
    else:
        return
    actual = apply(desired)
    applied_version = ver
    report = {"state": {"reported": {**actual,
              "appliedDesiredVersion": ver, "status": "applied"}},
              "clientToken": uuid.uuid4().hex[:16]}
    c.publish(PFX + "/update", json.dumps(report), qos=1)

On the backend side, always send version when more than one service can write desired state. Treat a 409 as a signal to re-read and re-evaluate, not as an error to retry blindly.

Azure IoT Hub Device Twin in Detail

An Azure IoT Hub device twin is a richer document with a looser contract. It has more structure and room, but no delta computation and no offline notification queue.

Structure and size limits

A twin has device identity properties, a tags section, and a properties section split into desired and reported. Tags are readable and writable only by back-end applications and are invisible to the device, which makes them the right place for fleet metadata such as site, customer or rollout ring. Desired properties are written by the back end and read by the device. Reported properties are written by the device.

Microsoft’s current limits are 8 KB for tags, 32 KB for desired and 32 KB for reported, excluding read-only elements such as $version and $metadata. IoT Hub computes size by summing UTF-8 lengths of keys and values, with numbers counted as 8 bytes and booleans as 4. Nesting depth is capped at 10 levels. Keys can be up to 1 KB and cannot contain control characters, dots, dollar signs or spaces; string values are capped at 4 KB.

Those limits are four times the AWS default per document, which is why Azure designs often keep everything in one twin while AWS designs split into named shadows. Bigger is not free, though. Every desired patch delivered to a device costs bandwidth on a constrained link, and a 30 KB reported document rewritten on every change will dominate a cellular data plan.

Azure also keeps $metadata with a $lastUpdated timestamp at every level of the object, not just on leaf values. That lets a back end see when each subtree last changed, which is useful for staleness detection on individual sensors inside a gateway twin.

Versions and ETags

Azure gives you two concurrency tools. Each of the desired and reported sections has a $version integer that the hub guarantees will always increase. The whole twin also has an etag, an RFC 7232 entity tag, which Microsoft describes as the only way to guarantee consistency for operations on the tags container. Back-end writers should send If-Match with the ETag when updating tags or desired properties from more than one service.

There is no computed delta. When a back end patches desired properties, a connected device receives the patch itself on $iothub/twin/PATCH/properties/desired/?$version={new-version}. The device must compare the patch against its own applied configuration. That is more work for the agent, but it also means the device sees intent exactly as written, including fields that already match reported values.

Notifications are lost while offline

The single most important line in the Azure documentation is that IoT Hub does not preserve desired-property update notifications for disconnected devices. A device that was offline during three desired changes receives none of those patches on reconnect. It must fetch the full twin. Microsoft prescribes an order to avoid races: connect, subscribe to desired notifications, then retrieve the full desired document, and ignore any notification whose $version is less than or equal to the retrieved version.

Azure IoT Hub device twin reconnect flow with desired property version check

Figure 3: Azure IoT Hub device twin reconnect sequence. The device subscribes before fetching, then discards a late desired patch whose version is not newer than the twin it already applied.

Figure 3 shows why the order matters. If the device fetched first and subscribed second, a desired change landing between the two calls would be missed until the next reconnect. Subscribing first creates the opposite risk, a duplicate, which the version check resolves cheaply. Duplicates are safe; gaps are not.

Back-end notification is also opt-in. Twin change notifications flow only if you configure a message route with the data source set to twinChangeEvents, and no such route exists by default. Under high change rates or internal failures, IoT Hub may collapse several changes into one notification. Microsoft states plainly that if you need every intermediate state for auditing, you should send device-to-cloud messages instead.

MQTT topics and throttles

IoT Hub’s native MQTT endpoint supports MQTT 3.1.1 on port 8883, or over WebSockets on 443, without QoS 2. A device subscribes to $iothub/twin/res/#, then publishes an empty body to $iothub/twin/GET/?$rid={id} to fetch the twin, and publishes a JSON patch to $iothub/twin/PATCH/properties/reported/?$rid={id} to report. Responses arrive on $iothub/twin/res/{status}/?$rid={id}, with 200 for a successful get, 204 for a successful reported update, 400 for malformed JSON and 429 for throttling.

import json, itertools
rid = itertools.count(1)
applied = {"version": 0}

def on_connect(c, u, flags, rc, props=None):
    c.subscribe("$iothub/twin/res/#", qos=1)
    c.subscribe("$iothub/twin/PATCH/properties/desired/#", qos=1)
    c.publish(f"$iothub/twin/GET/?$rid={next(rid)}", "", qos=1)

def on_message(c, u, msg):
    t = msg.topic
    if t.startswith("$iothub/twin/res/200/"):
        desired = json.loads(msg.payload)["desired"]
        converge(c, desired)
    elif t.startswith("$iothub/twin/PATCH/properties/desired/"):
        patch = json.loads(msg.payload)
        if patch.get("$version", 0) <= applied["version"]:
            return                     # stale: already covered by full GET
        converge(c, patch)
    elif t.startswith("$iothub/twin/res/429/"):
        schedule_backoff_retry()

def converge(c, desired):
    actual = apply_config(desired)     # idempotent, clamps values
    applied["version"] = desired["$version"]
    body = {**actual, "appliedDesiredVersion": desired["$version"],
            "status": "applied"}
    c.publish(f"$iothub/twin/PATCH/properties/reported/?$rid={next(rid)}",
              json.dumps(body), qos=1)

Note that a desired patch is merged into the existing desired document, so converge must merge patches into a local cached copy if the agent applies only the changed keys. The simplest safe approach is to keep the last full desired document locally and merge each newer patch into it.

The throttles are hub-wide, which changes capacity planning. Microsoft’s quota page lists twin reads at 100 per second and twin updates at 50 per second for Free and S1 hubs, regardless of unit count. S2 scales at 10 reads and 5 updates per second per unit above a floor, and S3 at 500 reads and 250 updates per second per unit. Requests over the limit receive 429 and should back off.

Twin queries

The feature that most distinguishes Azure is the SQL-like twin query over FROM devices and FROM devices.modules. You can filter on tags and reported properties, test with is_defined, and group and count, for example counting devices by reported firmware status. Microsoft caps query expressions at 8,192 characters, returns pages of at most 100 records, and lists a general query throttle of 20 per minute per unit on Free, S1 and S2.

Queries are eventually consistent. The documentation says results are typically returned within seconds but that delays of up to 30 minutes should be tolerated, and recommends Get Twin for the current state of a specific device. Treat queries as fleet analytics and rollout targeting, not as a source of truth for control decisions.

The IoT Central retirement context

Many teams met device twins through Azure IoT Central, a managed application layer on top of IoT Hub. In February 2024 a console message suggested retirement, and Microsoft then said the message was an error. The situation has since changed. A Microsoft Azure service notice, AZ569914, dated 29 September 2026, states that Azure IoT Central will be retired on 20 September 2029, and points customers to IoT Hub, the Device Provisioning Service, Azure Device Registry and Microsoft Fabric. I verified this notice through a public mirror of Azure service messages rather than the Azure portal itself, so check your own Service Health before planning. For most migrations the twin model carries over directly, because IoT Central’s device properties sit on IoT Hub twins underneath.

Eclipse Ditto in Detail

Eclipse Ditto is an open-source, self-hosted digital twin framework rather than a device connectivity service. It does not terminate device MQTT connections the way IoT Core or IoT Hub do. Instead it manages twins and connects to brokers, Kafka, AMQP endpoints or HTTP through its connectivity service. Our hands-on Eclipse Ditto digital twin tutorial walks through installation and the API; here the focus is the reconciliation contract.

Things, attributes, features and desired properties

A Ditto thing has an ID in namespace:name form, a policy ID, attributes for mostly static metadata, and features. Each feature has properties for current state, optional desiredProperties for target state, and an optional definition that links it to a type such as a W3C Web of Things Thing Model. The structure is closer to a typed object model than to a single JSON blob.

The Ditto documentation is unusually direct about what desiredProperties do. Properties describe what is; desired properties describe what should be. Ditto stores and indexes desired properties for search, but it does not reconcile them, and the documentation says reconciliation logic is up to your application or device firmware. Definitions are type annotations only; Ditto does not validate that properties match them.

That makes Ditto the least magical option. There is no delta topic. A device integration sees events such as a modified desired property and must compute the difference itself. In exchange, you get full control over how the difference is computed, including type-aware comparison of floats, units and arrays.

Policies as a first-class feature

Every thing references a policy, which grants READ and WRITE on resource paths to subjects such as OAuth or pre-authenticated identities. Permissions can target thing:/features/heating/desiredProperties separately from thing:/features/heating/properties. That lets you express “the operator app may write desired setpoint, only the device may write measured temperature” in the twin layer itself, rather than in topic ACLs.

Release 3.9.0 extended this heavily. According to the release announcement, it added namespace-scoped policy entries, namespace root policies that merge into every policy in a namespace, entry-level references and imports, a resolved policy view for debugging, and checkPermissions over WebSocket, AMQP and MQTT in addition to HTTP. For multi-tenant platforms that host twins for several customers, this is the main reason to choose Ditto.

Conditional requests

Ditto’s concurrency model combines HTTP entity tags with RQL conditions. Top-level resources return strong ETags of the form rev:<revision>, and sub-resources return hash-based tags. If-Match fails with 412 on mismatch. In addition, the condition header or query parameter accepts an RQL expression evaluated against the persisted twin, and the write is applied only if it is true. If both are present, the ETag check runs first.

# Only raise the desired setpoint if nobody changed the thing since we read it
curl -X PUT \
  -H 'Content-Type: application/json' \
  -H 'If-Match: "rev:57"' \
  "$DITTO/api/2/things/org.acme:thermostat-0042/features/heating/desiredProperties/setpoint" \
  -d '23.0'

# Only write desired state while the device reports itself as idle
curl -X PUT \
  -H 'Content-Type: application/json' \
  "$DITTO/api/2/things/org.acme:thermostat-0042/features/heating/desiredProperties/setpoint?condition=eq(features/heating/properties/mode,%22idle%22)" \
  -d '23.0'

The second form is more expressive than anything AWS or Azure offer natively. It turns guard conditions, such as “do not change the setpoint during a defrost cycle”, into atomic twin operations rather than read-check-write races.

Twin channel versus live channel

Ditto distinguishes the twin channel, which reads and writes the persisted twin, from the live channel, which routes a command to the device in real time. The live-channel-condition parameter lets a single GET choose dynamically: if an RQL condition matches, Ditto asks the device; otherwise it answers from the twin. A timeout strategy of use-twin falls back to persisted data when the device does not answer, while the default fail returns HTTP 408.

This addresses a real gap in the shadow model. Shadows always answer with last known state, which may be hours old. Ditto lets you ask for fresh state when it matters and tolerate staleness when it does not, with response headers telling the caller which channel answered.

Limits and operations

Ditto’s limits are configuration, not quota tickets. The default ditto-limits.conf sets a maximum thing size of 100k and a maximum policy size of 100k, overridable through the LIMITS_THINGS_MAX_SIZE and LIMITS_POLICIES_MAX_SIZE environment variables, with messages capped at 250k by default. Raising them is possible but shifts load onto MongoDB and the search index.

The price of that flexibility is operations. You run Ditto’s services, MongoDB, an ingress and a broker or Kafka cluster, and you own upgrades, backups and capacity. Release 3.9.0 moved to Java 25 and to a Helm chart at version 4.0.0 that no longer bundles an ingress-nginx controller, which is the kind of change a managed service would have absorbed for you.

Side-by-Side Comparison Table

The table below condenses the device shadow vs device twin contract differences, with Ditto alongside. Values reflect the vendor documentation and Ditto configuration checked on 11 November 2026; quotas and defaults change, so confirm them before committing.

Dimension AWS IoT Device Shadow Azure IoT Hub device twin Eclipse Ditto
Hosting model Managed, part of AWS IoT Core Managed, part of IoT Hub Self-hosted open source, EPL-2.0
Documents per device One classic plus named shadows One device twin plus module twins One thing with many features
State sections desired, reported, computed delta tags, desired, reported attributes, feature properties, desiredProperties
Size limit 8 KB per shadow, adjustable 8 KB tags, 32 KB desired, 32 KB reported 100k per thing by default, configurable
Nesting depth 8 levels 10 levels No documented fixed cap found
Delta computation Yes, on /update/delta No, raw desired patches No, events only
Offline notifications Only to subscribers; resync with get Not preserved; device must GET Not queued per device; resync by GET
Concurrency Document version, 409 on mismatch $version per section plus twin ETag Revision ETag, If-Match, RQL condition
Fleet query Fleet indexing, separate service SQL-like twin query, eventually consistent RQL search service over MongoDB
Access control IoT policies on topics and names Hub-level permissions Path-level policies per thing
Device protocol MQTT, HTTPS MQTT 3.1.1 or AMQP for twins Via connections to MQTT, AMQP, Kafka, HTTP
Main rate limit 20 rps per thing, 10 in flight Hub-wide twin reads and updates per tier Your cluster capacity
Pricing basis Per operation, 1 KB increments Included in hub tier and units Infrastructure you run

Two cells deserve emphasis. First, offline behaviour converges on the same rule everywhere: no platform holds a per-device queue of intent changes, so the device must read full state on reconnect. Second, only AWS computes a delta, and that convenience comes with array semantics you must design around.

AWS fleet indexing is a separate AWS IoT Device Management feature that can index shadow data for search; check its own pricing and named-shadow indexing configuration before relying on it, because it is not part of the shadow service quotas above.

Worked Numbers: Reconnect Storms, Throttles and Cost

A device shadow vs device twin comparison on paper hides the moment these systems are actually stressed: a regional network outage ends and an entire fleet reconnects at once. The following figures are illustrative calculations from published quotas, not benchmarks. Real throughput depends on Region, tier, payload size and client behaviour.

Scenario: 100,000 devices reconnect within minutes

Assume 100,000 devices, each of which performs one full twin read and one reported-state update on reconnect, which is exactly what the correct reconciliation pattern requires.

On AWS, the default account limit is 4,000 Device Shadow API requests per second. Two requests per device gives 200,000 requests, which drains in about 50 seconds at the full default rate. In a Region with the lower 400 per second default, it takes about 500 seconds, a little over eight minutes. Per-thing limits are irrelevant here because each thing makes only two calls.

On Azure with a single S1 hub, the twin read limit is 100 per second hub-wide and the update limit is 50 per second, regardless of how many S1 units you buy. 100,000 reads take 1,000 seconds, about 17 minutes, and 100,000 reported updates take 2,000 seconds, about 33 minutes. Adding S1 units does not help, which surprises many teams. On one S3 unit, at 500 reads and 250 updates per second, the same storm drains in roughly 200 and 400 seconds respectively.

The design consequence is the same on both platforms: add jittered backoff to reconnect logic. If 100,000 devices spread their reconnect uniformly over 20 minutes, the average arrival rate is about 83 per second, inside even the S1 read limit. Exponential backoff with full jitter on 429 responses keeps the tail from synchronising again. Our post on MQTT 5 shared subscriptions, broker clustering and backpressure covers the consumer side of the same storm.

Scenario: shadow operation cost on AWS

AWS bills Device Shadow and registry operations per million, metered in 1 KB increments of the record size. The pricing page uses $1.25 per million operations as its Canada (Central) example; other Regions differ. Messaging is billed separately at $1 per million for the first billion messages in its Europe (Ireland) example, metered in 5 KB increments.

Consider an illustrative fleet of 100,000 devices, each with a 1.5 KB named shadow updated six times a day. Each update meters as two operations because of the 1 KB rounding, so the fleet consumes 1.2 million shadow operations a day, about 36 million a month. At the example rate that is roughly $45 a month for shadow operations. The associated MQTT publishes and accepted or delta responses add messaging charges, typically comparable in magnitude.

The lesson is that shadow cost is driven by document size times write frequency. Trimming a document from 1.5 KB to under 1 KB halves the metered operations. Writing high-rate telemetry into reported state, say once a minute, multiplies operations by 240 and turns a rounding error into a line item. Telemetry belongs on ordinary topics, with the shadow holding slow-changing state.

Scenario: twin size headroom

On Azure, a gateway twin that reports 200 downstream sensors with a 40-byte key, a double value and a 30-byte status string per sensor uses roughly 200 times 78 bytes, about 15.6 KB, before metadata. That fits inside 32 KB reported with room for growth to roughly 400 sensors. On AWS the same payload would need two or three named shadows at the default 8 KB. On Ditto it fits in one thing at the default 100k, but each change event carries more context to downstream consumers.

When to Build Your Own Twin Store

Sometimes none of the three is the right home for desired and reported state. The decision rests on cloud commitment, modelling needs and how standardised the device management contract must be.

Decision tree for device shadow vs device twin platform choice and building your own

Figure 4: Choosing between AWS Device Shadow, Azure IoT Hub twins, Eclipse Ditto, LwM2M or a home-built store. Every branch still ends with a device-side reconciler you write.

Figure 4 starts with cloud commitment because that dominates. If devices already authenticate to AWS IoT Core or Azure IoT Hub, using the native shadow or twin is almost always cheaper than running a second system. If you are multi-cloud, on-premises or need rich path-level access control and search, Ditto earns its operational cost. If the requirement is standardised device management across vendors, such as firmware, connectivity and server objects with defined semantics, an LwM2M server provides that contract.

A home-built store makes sense in narrower cases. You may already run Postgres and an MQTT broker, need transactional joins between twin state and business data, or need per-field history that no managed twin retains. A table with device ID, desired JSONB, reported JSONB and two version columns, plus a broker topic per device, reproduces the core of the pattern in a few hundred lines. Our guide to CRDTs for offline-first twin synchronization covers the case where both sides legitimately write the same fields.

Before building, price what you give up. Managed services handle authentication, per-device authorisation on topics, multi-AZ durability and quota enforcement. A homegrown store must also solve change notification, fan-out to rules engines and protection against a single device hammering its own row. Most teams underestimate that last item until a firmware bug ships.

Trade-offs, Gotchas, and What Goes Wrong

The failure modes below come from the contracts described above. Each one has bitten production fleets, and each has a cheap mitigation if you know to look for it.

Desired state that never clears. Teams use desired state for one-shot commands such as “reboot”. On AWS, the delta persists until reported matches, so a device that reboots without reporting will reboot again on every reconnect. On Azure, the desired property persists forever. Use jobs or direct methods for commands, or have the agent report a command ID and the back end null the desired field once acknowledged.

Array deltas that explode. Because AWS treats arrays as atomic values, a 200-entry allowlist with one changed element produces a 200-entry delta. That can push the response close to the document limit and triggers full re-application on the device. Model collections as maps keyed by stable IDs.

Blind writes from multiple services. On AWS, omitting version turns every update into last-writer-wins. On Azure, desired patches without If-Match silently overwrite each other. The symptom is an operator change that “reverts itself” when an automation job writes the old value seconds later. Require versions or ETags in every back-end writer, and centralise desired-state writes behind one service if possible.

Echoing desired into reported. As described earlier, copying values hides clamping, unit conversion and partial failure. It also defeats AWS deltas, because the shadow believes the device converged when it did not.

Twin queries used for control. Azure twin queries may lag by up to 30 minutes according to Microsoft. A rollout controller that queries “devices on firmware 2.3” and immediately pushes to them will target stale sets. Use queries to pick candidates, then confirm with Get Twin per device or use the hub’s automatic device configuration features.

Gateway thundering herds. Per-thing limits on AWS and hub-wide limits on Azure both punish gateways that write many twins in one burst. Rate-limit inside the gateway and coalesce changes per scan cycle.

Telemetry in reported state. Reported state is for slowly changing facts. Streaming sensor values through it costs more per byte, loses intermediate values when Azure batches notifications, and races with configuration reports. Keep a separate telemetry path, ideally with store-and-forward at the edge, as described in our MQTT store-and-forward edge gateway design.

Clock-based ordering. Comparing $lastUpdated or metadata timestamps to decide which change wins fails under clock skew and batching. Use versions.

Self-hosting debt with Ditto. Ditto’s flexibility hides real operational weight. Policy imports and namespace root policies are powerful, but a misconfigured root policy can grant access across an entire namespace. Use the resolved policy view in review, and test policy changes in a staging namespace.

Practical Recommendations

In the device shadow vs device twin decision, the platform is usually chosen by your existing cloud commitment, and that is fine. The bigger quality lever is the reconciliation discipline you apply on top. Write one device agent pattern and use it everywhere: subscribe, fetch full state, converge idempotently, report evidence with the applied desired version, and drop anything stale.

On AWS, split state into named shadows by owner and change rate, keep each under 1 KB where possible to control metering, avoid arrays, and require version on back-end writes. On Azure, put fleet metadata in tags, keep reported state lean despite the 32 KB room, add twinChangeEvents routing if back ends must react, and plan hub tier for reconnect storms, not average load. On Ditto, model features with definitions, put write permissions on desiredProperties paths explicitly, use RQL conditions for guarded writes, and budget engineers for operations.

Whatever you pick, separate three channels clearly: state in the twin, telemetry on ordinary topics, and commands through jobs, direct methods or Ditto messages.

A short checklist before you ship:

  • Device agent fetches full desired state on every connect, after subscribing.
  • Every notification is version-checked and stale ones are dropped.
  • Reported state includes applied values, applied desired version and a status.
  • Every back-end writer sends a version, ETag or RQL condition.
  • No arrays in AWS desired state; collections modelled as maps.
  • Reconnect uses exponential backoff with jitter, and 429 is handled.
  • A staleness alarm fires when reported state is older than your SLA.
  • Telemetry and commands do not travel through desired or reported state.

Frequently Asked Questions

What is the difference between a device shadow and a device twin?

They are the same pattern with different names. AWS IoT calls it a device shadow; Azure IoT Hub calls it a device twin. Both store a JSON document with desired state written by applications and reported state written by the device. The practical differences are contract details: AWS computes a delta and supports named shadows of 8 KB each by default, while Azure offers tags, 32 KB desired and reported sections, per-section versions and SQL-like fleet queries.

What happens to desired state changes when a device is offline?

The desired state is stored, but the change notifications are not reliably queued for the device. Azure states explicitly that desired-property notifications are not preserved for disconnected devices. On AWS, a persistent MQTT session may hold some QoS 1 messages, but correct agents should not depend on that. The safe pattern on all platforms is to subscribe on reconnect, fetch the full desired document, converge to it, and ignore any notification with a version not newer than the fetched document.

How large can an AWS IoT device shadow be?

The AWS IoT Core quota page lists a maximum of 8 KB per shadow document, adjustable through a quota request, and metadata does not count toward that size. Desired and reported sections can nest at most 8 levels deep, which is not adjustable. If your state is larger, split it across named shadows by concern, such as firmware, network and application settings. Keeping each document small also lowers cost, because shadow operations are metered in 1 KB increments.

How large can an Azure IoT Hub device twin be?

Microsoft documents 8 KB for tags, 32 KB for desired properties and 32 KB for reported properties, excluding read-only metadata such as $version and $lastUpdated. Size is calculated from the UTF-8 length of keys and values, with numbers counted as 8 bytes and booleans as 4. Nesting is limited to 10 levels, keys to 1 KB and string values to 4 KB. Updates that would exceed a limit are rejected with an error.

Does Eclipse Ditto automatically sync desired properties to devices?

No. Ditto stores and indexes desiredProperties on each feature and emits events when they change, but its documentation says reconciliation is up to your application or device firmware. A connection or device integration must subscribe to those events, apply the change and update the feature’s properties. What Ditto adds is fine-grained policies, ETag-based and RQL-conditional writes, and a live channel that can query the device directly when the persisted twin is not fresh enough.

Should I build my own device twin store instead?

Usually not, if you already use AWS IoT Core or Azure IoT Hub, because their twins come with authentication, authorisation and durability included. Building makes sense when you need transactional joins with business data, per-field history, on-premises deployment without Ditto’s footprint, or a protocol the platforms do not support. If you build, implement versions, conditional writes, change notification and per-device rate limiting from day one, since those are the parts that fail first.

Further Reading

References

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *