Learning/AWS SQS/08 — Visibility Timeout
Advanced 40 min read

Visibility Timeout

The single most consequential setting in SQS, and the most commonly misconfigured.

Set it too low and you process everything twice. Set it too high and a crashed consumer's messages sit frozen for an hour. Most teams pick 30 seconds because that is the default, discover a problem, raise it to 5 minutes, and create a different problem.

This module gives you a method for choosing it rather than a number to copy — and then explains why no value you choose can eliminate duplicate processing.

1. What you will learn

  • Explain what visibility timeout is, and precisely when the timer starts and stops
  • Explain the race condition that causes duplicate processing, at sequence-diagram level
  • Extend a timeout safely with ChangeMessageVisibility and a heartbeat
  • Choose a timeout from your processing-time distribution, not from a guess
  • Explain why no timeout value removes the need for idempotency

2. The problem it solves

A consumer receives a message. Thirty seconds later, SQS has heard nothing back.

What happened?

PossibilityWhat SQS should do
The consumer crashedGive the message to someone else — urgently
The consumer is still working, legitimatelyLeave it alone
The consumer finished but the delete is slowLeave it alone

SQS cannot tell these apart. It has no health check on your consumer, no heartbeat, no connection to monitor. From SQS's side, all three look identical: a message was handed out and no delete has arrived.

The two obvious designs both fail:

  • Delete on receive. A crashed consumer loses the message permanently. That is Module 01's failure 2 — work lost, not delayed — reintroduced.
  • Never redeliver until told. A crashed consumer strands the message forever. The queue slowly fills with messages nobody will ever process.

So SQS does the only remaining thing: it guesses, with a timer.

📌 The visibility timeout is SQS's answer to a question it cannot answer directly: "is this consumer still alive?" It substitutes elapsed time for liveness. Every property of this setting follows from the fact that it is a guess about the future.

Why not add a health check? Because that is push-model thinking, and SQS is deliberately stateless with respect to consumers (Module 02 §5). SQS does not know your consumers' addresses, does not track them, and does not want to — that is what lets you scale from 1 to 1,000 consumers with no registration. The timer is the price of that statelessness.

3. What it actually does

Visibility timeoutSimple: after a consumer receives a message, SQS hides it from everyone else for a while. Technical: a per-message lease, started when ReceiveMessage returns the message, during which the message remains stored but is not returnable to any other ReceiveMessage call. On expiry without a DeleteMessage, the message becomes available again and its receive count increments. Example: default 30 seconds; range 0 seconds to 12 hours (aws-facts.md §2).

Three facts that are easy to state and easy to get wrong:

1. The timer starts when SQS returns the message — not when your code begins processing it. If your consumer receives a batch of 10 and processes them serially, message #10's timer has been running the whole time message #1 was being processed (Module 13). Time spent in your own thread-pool queue is timeout budget already spent.

2. It is a lease, not a deletion. The message is still in the queue, counted by ApproximateNumberOfMessagesNotVisible.

3. Setting it to zero releases the message immediately. ChangeMessageVisibility(0) is the standard fail-fast retry.

Where it can be set

LevelHowScope
QueueVisibilityTimeout attributeDefault for all receives
Per receiveReceiveMessage(visibilityTimeout=N)That call's messages
Per messageChangeMessageVisibility(handle, N)That one in-flight message
BatchChangeMessageVisibilityBatchUp to 10 at a time

4. Two timelines

The same 30-second lease, two different workloads. This single comparison is the whole module.

✅ Work fits inside the lease

       0s        10s       20s       30s       40s
       |---------|---------|---------|---------|
lease  ████████████████████████████████            ← 30s, expires at 30
work   ██████████████████░                         ← 18s, then DeleteMessage
                          ↑
                          delete lands at 18s, 12s of headroom
                          lease discarded unused

result: processed once ✅

⚠️ Work overruns the lease

       0s        10s       20s       30s       40s
       |---------|---------|---------|---------|
lease  ████████████████████████████████            ← expires at 30 — no delete arrived
work A ████████████████████████████████████░       ← still running at 30s!
                                    ▓▓▓▓▓▓▓▓░      ← consumer B receives at 30s
                                    ↑       ↑
                                    │       └─ B deletes at 40s ✅
                                    └───────── A and B now both processing
                                               the same message

       A's delete at 40s uses a stale handle → does nothing

result: processed TWICE ⚠️ — and nothing logged an error

Reading the timelines. The only difference between them is the relationship between one number you chose and one number you measured. When processing time crosses the lease, the message is handed to a second consumer while the first is still working — and the first consumer's eventual delete is a no-op, because its receipt handle refers to a lease that has already expired (Module 04 §6).

⚠️ Note what does not happen in the second timeline: no exception, no error response, no failed API call. Every individual operation succeeded. This failure is silent unless you are watching ApproximateReceiveCount or observing duplicate side effects downstream.

5. The race condition

Now the same message with 40 seconds of work and the same 30-second timeout.

sequenceDiagram
    autonumber
    participant A as Consumer A
    participant S as SQS
    participant B as Consumer B
    participant D as Database

    A->>S: ReceiveMessage
    S-->>A: msg + handleA (count=1)
    Note over S: lease starts · 30s

    A->>A: processing… (will take 40s)

    Note over S: t=30s — lease expires.<br/>No delete arrived.
    S->>S: message becomes Available

    B->>S: ReceiveMessage
    S-->>B: same msg + handleB (count=2)
    Note over S: new lease · 30s

    par A is still working
        A->>D: write ✅
        A->>S: DeleteMessage(handleA)
        Note over S: handleA is stale.<br/>Does not delete the current receive.
    and B is also working
        B->>D: write AGAIN ⚠️
        B->>S: DeleteMessage(handleB)
        S-->>B: 200 — deleted
    end

Reading the diagram. Three failures compounding, all from one number being too small:

  1. Two consumers processed the same message simultaneously. Both wrote to the database.
  2. Consumer A's delete did nothing, because handleA referred to a lease that had already expired (Module 04 §6).
  3. Nothing logged an error. Every individual call succeeded. This failure is completely silent unless you are watching ApproximateReceiveCount or duplicate side effects.

⚠️ This is not an edge case. If your p99 processing time exceeds your visibility timeout, 1% of your messages are processed twice, continuously, by design.

6. Choosing the value

Use the p99, not the mean

The most common sizing error is using average processing time. Consider a workload with a mean of 2 seconds and a p99 of 25 seconds — a perfectly ordinary distribution once a database occasionally gets slow.

Timeout chosen fromValueMessages that overrun
Mean × 24 sFar more than 1% — everything in the tail
p9925 s~1%
p99 × 250 sWell under 1%

📌 The mean tells you about typical messages. The timeout is only ever tested by atypical ones. Size from the tail.

The method

1. Measure processing time and record a histogram — p50, p95, p99, p99.9.
   (SQS does not give you this. You must emit it — Module 17.)

2. Include in-process queueing time. The timer started at ReceiveMessage,
   not when your worker picked the message off its internal queue.

3. timeout = p99 × safety factor (2 is a reasonable starting point)

4. If p99.9 is far above p99 — a long tail — do NOT pick a bigger constant.
   Heartbeat instead (§7).

5. Check the interaction:  maxReceiveCount × timeout = time to DLQ.
   Confirm that is acceptable, and far below your retention period.

6. Re-measure after any change to the downstream. A timeout sized against a
   fast database is wrong the day that database slows down.

Too low versus too high

Too lowToo high
SymptomDuplicate processing, wasted capacity, downstream load amplificationSlow recovery from crashes, slow arrival at the DLQ
Who noticesThe downstream, and eventually a customerWhoever is on call during an incident
CostEvery overrunning message is done twiceA crashed consumer's messages are frozen for the full timeout
DetectionApproximateReceiveCount > 1, duplicate side effectsApproximateAgeOfOldestMessage rising in steps

The asymmetry worth internalising: too low costs you continuously; too high costs you during an incident, which is exactly when you can least afford it. With a 12-hour timeout, a consumer fleet that crashes leaves every in-flight message invisible for twelve hours. No amount of restarting helps — the messages are leased to processes that no longer exist.

🎯 Interview point. "How do you choose a visibility timeout?" — p99 of processing time, including in-process queueing, times a safety factor; heartbeat if the tail is long; then check the product with maxReceiveCount against retention. Naming the p99 and the interaction is what separates a good answer from a textbook one.

7. Heartbeating

When processing time is genuinely variable — 20 seconds to 9 minutes — no single constant is right. A timeout sized for the maximum makes crash recovery terrible; one sized for the median guarantees duplicates.

The answer is to extend the lease while you work.

sequenceDiagram
    autonumber
    participant C as Consumer
    participant H as Heartbeat thread
    participant S as SQS

    C->>S: ReceiveMessage
    S-->>C: msg + handle
    Note over S: lease · 60s

    C->>H: start(handle, every 20s)
    C->>C: long job begins

    H->>S: ChangeMessageVisibility(handle, 60)
    Note over S: lease reset to 60s FROM NOW
    H->>S: ChangeMessageVisibility(handle, 60)
    Note over S: lease reset again
    H->>S: ChangeMessageVisibility(handle, 60)

    C->>C: job completes ✅
    C->>H: stop()
    C->>S: DeleteMessage(handle)
    S-->>C: 200

Reading the diagram. Two details that matter:

  • The extension resets the lease from now, not from the original receive. Each heartbeat buys another full 60 seconds.
  • Heartbeat well inside the window — here every 20 seconds against a 60-second lease. If a heartbeat is missed (GC pause, network blip) you still have two more chances before the lease lapses.

The implementation

Java
final class VisibilityHeartbeat implements AutoCloseable {

    private final ScheduledExecutorService scheduler =
            Executors.newSingleThreadScheduledExecutor(r -> {
                Thread t = new Thread(r, "sqs-heartbeat");
                t.setDaemon(true);      // must never hold JVM shutdown open
                return t;
            });

    private final ScheduledFuture<?> task;

    VisibilityHeartbeat(SqsClient sqs, String queueUrl, String receiptHandle,
                        int leaseSeconds) {
        // Renew at a third of the lease: two missed beats are survivable.
        long periodSeconds = Math.max(1, leaseSeconds / 3);

        this.task = scheduler.scheduleAtFixedRate(() -> {
            try {
                sqs.changeMessageVisibility(r -> r
                        .queueUrl(queueUrl)
                        .receiptHandle(receiptHandle)
                        .visibilityTimeout(leaseSeconds));
            } catch (ReceiptHandleIsInvalidException | MessageNotInflightException e) {
                // The lease is already gone — the message has been redelivered.
                // Extending is impossible; stop trying and let the work finish or abort.
                log.warn("lease lost, message already redelivered", e);
                task.cancel(false);
            } catch (Exception e) {
                // Transient: let the next beat retry. Do NOT cancel.
                log.warn("heartbeat failed, will retry", e);
            }
        }, periodSeconds, periodSeconds, TimeUnit.SECONDS);
    }

    @Override public void close() {
        task.cancel(false);
        scheduler.shutdownNow();
    }
}

Used as:

Java
try (var hb = new VisibilityHeartbeat(sqs, queueUrl, m.receiptHandle(), 60)) {
    process(m);                                    // may take minutes
}                                                  // heartbeat stops here
sqs.deleteMessage(d -> d.queueUrl(queueUrl).receiptHandle(m.receiptHandle()));

Four decisions in that code worth noting:

DecisionWhy
Daemon threadA non-daemon heartbeat thread prevents JVM shutdown (Module 22)
Renew at lease / 3Survives two consecutive missed beats
Distinguish "lease lost" from "call failed"Lease lost is terminal — stop. A transient error should retry on the next beat
try-with-resourcesThe heartbeat stops even if process() throws

The 12-hour ceiling

⚠️ No chain of extensions can exceed 12 hours from the original receive (aws-facts.md §2). At 12 hours the message is released regardless of how diligently you have been heartbeating.

If your work can exceed 12 hours, SQS is not the right place to track it. Store the job in a database, use the message only to trigger it, and delete the message as soon as the job is durably recorded — this is the waitForTaskToken pattern in Step Functions (Module 21).

8. Fail fast with ChangeMessageVisibility(0)

The inverse operation. When a consumer knows immediately that it cannot process a message — a dependency is down, a circuit breaker is open — waiting out the full timeout is pure latency.

Java
catch (DownstreamUnavailableException e) {
    // Return it now rather than holding the lease for 30 unproductive seconds
    sqs.changeMessageVisibility(r -> r
            .queueUrl(queueUrl)
            .receiptHandle(m.receiptHandle())
            .visibilityTimeout(0));
}

⚠️ Do not use this for a retry loop against a failing downstream. Releasing immediately means immediate redelivery, which means maximum pressure on a system that is already failing — and the receive count climbs toward the DLQ at full speed. Module 10 shows the correct use: ChangeMessageVisibility(backoffDelay), not zero.

9. The interaction with maxReceiveCount

These two settings are frequently tuned independently. They should not be.

time before a message reaches the DLQ  ≈  maxReceiveCount × visibilityTimeout
maxReceiveCountTimeoutTime to DLQ
530 s2.5 minutes ✅
55 min25 minutes ⚠️
105 min50 minutes ⚠️
1030 min5 hours 🛑
10012 h50 days — exceeds retention; the message expires instead 🛑

Two things to check whenever you change either one:

  1. Is time-to-DLQ acceptable? A poison message occupies a worker slot on every attempt. At 5 hours, it is consuming capacity all day.
  2. Is it far below retention? If maxReceiveCount × timeout approaches your retention period, messages expire before reaching the DLQ — you lose both the message and the evidence (Module 05 §6).

📌 Raising the visibility timeout silently raises time-to-DLQ by the same factor. This is the hidden cost of the "just make it bigger" fix.

Note also that on standard queues the formula is a lower bound: after three receives a message may be moved to the back of the queue, adding unpredictable delay (Module 05 §8).

10. Visibility timeout with Lambda

Lambda adds its own constraint.

AWS guidance: set the queue's visibility timeout to at least 6× the function timeout (aws-facts.md §8).

Why 6×? Because the Lambda service may retry the same batch within one lease. If visibility equalled the function timeout, a single invocation consuming its full budget would leave zero headroom — the lease would expire at precisely the moment the function timed out, and the batch would be redelivered while Lambda was still deciding what to do with it. The multiple gives room for retries and for the deletion round trip.

Function timeout      : 30 s
Visibility timeout    : ≥ 180 s

⚠️ Setting visibility equal to the function timeout is one of the most common Lambda + SQS misconfigurations, and it manifests as unexplained duplicate processing (Module 19).

11. Production considerations

Size from the p99, including in-process queueing time. The timer starts at ReceiveMessage; everything between that and your business logic is budget already spent.

Heartbeat for long or variable work rather than choosing a large constant. A large constant makes crash recovery as slow as the constant.

Deployments interact with this directly. A rolling restart kills consumers holding in-flight messages. Each of those waits out the full visibility timeout before redelivery. With a 5-minute timeout, every deploy creates a 5-minute latency spike plus a burst of duplicate processing — which is why graceful shutdown (Module 22) and this setting must be sized together.

A graceful shutdown period longer than the visibility timeout is pointless. If shutdown takes longer than the lease, the message has already been redelivered and your careful draining is doing work someone else is also doing.

Re-measure after downstream changes. A timeout sized against a 50 ms database is wrong when that database starts taking 400 ms. This is a leading cause of "duplicates suddenly appeared and nothing changed".

Watch for a rising ApproximateReceiveCount distribution. A shift from "almost everything is 1" to "a few percent are 2" is the earliest signal that processing time has crept past the timeout.

12. Common mistakes

Sizing from average processing time. Why it's wrong: the timeout is only tested by slow messages, and the mean says nothing about them. Instead: p99 × a safety factor.

Setting it very high "to be safe". Why it's wrong: crash recovery becomes as slow as the timeout, and time-to-DLQ scales with it. Instead: heartbeat.

Believing a large enough timeout eliminates duplicates. Why it's wrong: crashes, deploys, network partitions and replication all produce duplicates independently of the timeout. Instead: idempotency (Module 09).

Deleting with a handle after the lease expired. Why it's wrong: the handle is stale; the delete does nothing and the message is processed again. Instead: heartbeat, or check elapsed time before deleting.

Forgetting the timer starts at receive. Why it's wrong: in-process queueing silently consumes the budget — a batch of 10 processed serially means message 10's lease has been running the whole time. Instead: count the whole in-process wait.

Using ChangeMessageVisibility(0) as a retry mechanism. Why it's wrong: immediate redelivery, maximum pressure on a failing downstream, receive count racing to the DLQ. Instead: extend by a backoff delay (Module 10).

Tuning visibility timeout without re-checking maxReceiveCount. Why it's wrong: time-to-DLQ scales with the product of the two. Instead: compute the product, and check it against retention.

13. Real-world example

A video transcoding service. Messages reference an S3 object; the worker transcodes it.

The distribution

p50   :  22 s     (short clips)
p90   :  95 s
p99   : 310 s     (~5 min)
p99.9 : 540 s     (~9 min — long-form uploads)
max   : ~8 min

A distribution with a 25× spread between p50 and p99.9. No constant is correct here, and that is the point of the example.

What they tried

Attempt 1 — 30 seconds (the default). Everything above p50 overran. Roughly half of all messages were processed at least twice. The transcoding cluster was doing nearly double the necessary work, and S3 write costs doubled. Nobody noticed for three weeks because the output was correct — the second transcode simply overwrote the first.

Attempt 2 — 10 minutes. Duplicates from overrun stopped. Two new problems:

  • When a worker was OOM-killed, its messages stayed invisible for the full 10 minutes. Recovery from an instance failure went from seconds to ten minutes.
  • With maxReceiveCount = 5, a genuinely poison message now took 50 minutes to reach the DLQ, occupying a transcoding slot on every one of its five attempts.

Attempt 3 — heartbeat, and this is the answer.

Queue visibility timeout : 120 s    (covers p90 with headroom, no heartbeat needed for most)
Heartbeat period         :  40 s    (lease / 3)
maxReceiveCount          :   3
Time to DLQ              : ~6 min   ✅
Crash recovery           : ~2 min   ✅
Duplicates from overrun  : ~0       ✅

Long jobs extend their own lease as they go. Short jobs — the majority — finish inside the original 120 seconds and never heartbeat at all, so the extra API calls are paid only where needed.

The subtlety they hit next

The heartbeat thread was initially created non-daemon. Every deploy, the JVM refused to exit until the executor was shut down, ECS waited out stopTimeout and then sent SIGKILL — producing exactly the burst of redeliveries the heartbeat was meant to prevent (Module 22).

One word — setDaemon(true) — was the fix. It is in the §7 implementation for this reason.

🎯 The transferable lesson: visibility timeout, heartbeating, graceful shutdown and maxReceiveCount are one interconnected design. Tuning any of them in isolation moves the problem rather than solving it.

14. Interview questions

🟢 Beginner

What is visibility timeout? The period after a consumer receives a message during which SQS hides it from other consumers. The message stays in the queue. If the consumer deletes it within that window it is gone; if not, it becomes available again.

What happens if processing takes longer than the visibility timeout? The message becomes visible again and another consumer can receive it, while the first consumer is still working — so the message is processed twice. The first consumer's delete then fails silently, because its receipt handle is stale.

🟡 Intermediate

What is ChangeMessageVisibility for? Adjusting the lease on an in-flight message. Extending it keeps a long-running job safe from redelivery; setting it to zero releases the message immediately for a fast retry. The new value runs from the moment of the call, not from the original receive.

Why does the timer start at ReceiveMessage rather than when processing begins? Because SQS has no visibility into your process. It knows only when it handed the message over. This matters in practice: a batch of 10 processed serially means the tenth message's lease has been running for the entire time the first nine were processed.

🔴 Advanced

How do you choose a visibility timeout? Walk through your reasoning.

Measure the processing-time distribution — which means emitting it yourself, since SQS does not provide it. Take the p99, not the mean: the timeout is only ever tested by slow messages, so the central tendency is the wrong statistic.

Include in-process queueing time, because the lease started at ReceiveMessage, not when a worker thread picked the message up.

Apply a safety factor of around 2. Then check two interactions: maxReceiveCount × timeout is your time to DLQ and must be acceptable and far below retention; and the timeout must exceed your graceful-shutdown budget, or draining on deploy achieves nothing.

If p99.9 is far above p99 — a long tail — do not pick a larger constant. Heartbeat, so the common case stays fast and only the outliers pay for extensions.

Can you set a visibility timeout high enough to guarantee no duplicate processing?

No, and it is worth being precise about why. A large timeout only addresses duplicates caused by processing overrun. It does nothing about:

  • A consumer crashing after doing the work and before deleting — the lease expires regardless
  • A deploy terminating a worker mid-message
  • A network partition losing the delete
  • Replication: a delete that does not reach every replica (Module 03 §6)

And it makes things actively worse in one respect: a longer lease means a crashed consumer's messages are frozen for longer, so recovery time equals the timeout you chose.

The honest framing is that visibility timeout is a tuning parameter for how often duplicates occur, not a mechanism for preventing them. Prevention of the effect is idempotency (Module 09).

How do visibility timeout and maxReceiveCount interact to determine time-to-DLQ? Multiplicatively: roughly maxReceiveCount × visibilityTimeout. Raising the timeout from 30 seconds to 5 minutes with maxReceiveCount = 10 moves time-to-DLQ from 5 minutes to 50. A poison message then occupies a worker slot on each of ten attempts across nearly an hour. On standard queues this is a lower bound, since a message received three or more times may be moved to the back of the queue. The product must also stay well below the retention period, or messages expire instead of reaching the DLQ.

Why is a graceful shutdown period longer than the visibility timeout pointless? Because once the lease expires the message has already been redelivered to another consumer. Your draining worker is now doing work that someone else is simultaneously doing — the opposite of what graceful shutdown was for. The two settings must be sized against the same p99.

⚫ System design

Design a consumer for jobs whose duration varies from seconds to hours.

Start by establishing the range, because it determines the architecture rather than just the settings.

If the maximum is under 12 hours:

  • Set the queue timeout to cover the common case comfortably — around p90 — so most messages never need an extension.
  • Heartbeat for the tail, renewing at a third of the lease so two missed beats are survivable.
  • Run the heartbeat on a daemon thread, and stop it in a finally or try-with-resources so a thrown exception does not leave it running.
  • Distinguish a lost lease (ReceiptHandleIsInvalid, MessageNotInflight — terminal, stop extending, and ideally abort the work since someone else now owns it) from a transient API failure (retry on the next beat).
  • Keep maxReceiveCount low, because time-to-DLQ scales with the lease.
  • Idempotency is non-negotiable — long jobs have more opportunity to be interrupted, so duplicates are more likely, not less.

If the maximum can exceed 12 hours, SQS should not hold the lease at all. The 12-hour ceiling is absolute. Instead:

  1. The message triggers the job rather than representing it.
  2. The consumer writes a job record to a database, durably, and deletes the message immediately — within seconds.
  3. A separate mechanism tracks the long-running work: a state machine with waitForTaskToken (Module 21), or a worker that updates job state and a sweeper that detects stalled jobs.

The general principle worth stating: do not use a queue lease as a distributed lock for long-running work. The lease is bounded, it is a guess, and it was designed for seconds-to-minutes processing. Once your work outlives it, you need explicit job state.

Follow-ups to expect: "What if the heartbeat thread itself dies?" — the lease lapses and the message is redelivered, which is the safe failure direction; monitor for it via receive count. "How do you stop two workers doing the same long job?" — you cannot prevent it at the queue layer; you need a lock or a conditional write in the job record, which is idempotency again.

15. Summary

  • The visibility timeout is SQS's substitute for a liveness check it cannot perform. It converts "is this consumer alive?" into "has enough time passed?"
  • The timer starts at ReceiveMessage, so in-process queueing spends the budget.
  • Size it from the p99, including queueing, times a safety factor — never from the mean.
  • Heartbeat for long or variable work instead of choosing a large constant; renew at a third of the lease, on a daemon thread, stopped in finally.
  • 12 hours is an absolute ceiling from the original receive, however many extensions you chain.
  • maxReceiveCount × visibilityTimeout ≈ time to DLQ — check it against retention.
  • Too low costs you continuously; too high costs you during an incident.
  • With Lambda: visibility ≥ 6× the function timeout.
  • No value of this setting removes the need for idempotency.

← Previous: 07 — FIFO Queues · Index: Course home · Next: 09 — Delivery Semantics and Idempotency