pattern we see constantly: an enterprise signs for 100 SMS per second, points its application at the bind, and measures twelve. Bandwidth is fine. The SMSC is fine. Nobody has been throttled. The traffic is simply crawling, and the first instinct is always to blame the route. It is almost never the route. It is the window.
SMPP is an asynchronous protocol that most integrations accidentally run synchronously. Understanding why is the difference between paying for throughput and receiving it.
What the window actually is
The window is the number of PDUs you may have submitted but not yet had acknowledged, on a single bind, at any one moment. Send a submit_sm and the SMSC replies with submit_sm_resp — but SMPP never required you to wait for that reply before sending the next message. The window is how far ahead you are allowed to run.
With a window of 1, you submit, you wait, you receive, you submit again. Your throughput is now bounded by the round-trip time, not by anything you agreed commercially. At 40 ms round trip that is 25 messages per second — no matter what your contract says. With a window of 10 you have ten in flight at once and the same link carries roughly ten times the traffic over the identical connection.
Window size is the single biggest determinant of the throughput you get from a bind, and it is the setting most often left at a library default that silently caps an integration far below its agreed rate.
Why so many libraries default to one
A window of 1 is the safe default for a library author. It needs no correlation state, no timeout bookkeeping, and no reordering: every request has exactly one outstanding response, so a naive client can treat SMPP as request/response and never notice the difference. It works, it is easy to reason about, and it is slow. Many SMPP client libraries ship this way, and the value never gets revisited because nothing appears broken — the messages do arrive, just not quickly.
Sequence numbers are what make a window possible
Every SMPP PDU carries a sequence_number in its 16-octet header. When several submissions are in flight simultaneously, that number is the only thing tying each submit_sm_resp back to the submit_sm that caused it. Responses are not guaranteed to arrive in the order the requests were sent, so a client running a window larger than 1 must keep a map of outstanding sequence numbers and match on arrival.
Two rules matter. Never reuse a sequence number while it is still in flight — a late response to a recycled number will be attributed to the wrong message. And always time out entries: if a response never arrives, the slot must be released or the effective window shrinks to zero over the life of the session, which produces the maddening symptom of a bind that starts fast and degrades over hours.
You can watch this happen field by field in our SMPP session simulator — it shows the sequence_number in the header of every PDU, and which response is answering which request.
Window size and TPS are different limits
These get conflated constantly. TPS is a rate limit the operator enforces: submissions per second, measured over a window of time. The SMPP window is a concurrency limit you configure: PDUs outstanding at an instant. They interact but they are not the same number.
The relationship is roughly: achievable TPS ≈ window ÷ round-trip time. A window of 10 with a 50 ms round trip gives about 200 TPS. If the operator caps you at 100, you will hit the cap and receive ESME_RTHROTTLED on the excess. If the operator allows 500 and your window is 10, you will never see 500 no matter how much traffic you offer — you are concurrency-bound, not rate-bound.
| Window | Round trip | Ceiling | Symptom |
|---|---|---|---|
| 1 | 40 ms | ~25 TPS | Contracted rate never approached; link looks idle |
| 10 | 40 ms | ~250 TPS | Healthy for most enterprise senders |
| 50 | 40 ms | ~1,250 TPS | Only if the operator agreed it; expect throttling otherwise |
| 10 | 250 ms | ~40 TPS | Latency-bound — raise the window or move closer |
Reading ESME_RTHROTTLED correctly
ESME_RTHROTTLED (0x00000058) means you exceeded the agreed submission rate. It is not a rejection of the message and it is not a routing failure. The correct response is to back off and retry that message — treating it as permanent silently drops traffic that the operator would have accepted a moment later.
It is also the signal that your window is now too large for the rate you hold. If raising the window produced throttling rather than throughput, the constraint has moved from concurrency to contract, and the next conversation is commercial rather than technical.
enquire_link: the other reason binds die
A bind that sends nothing looks identical to a bind whose TCP connection has silently died — NAT timeouts and stateful firewalls drop idle sessions without telling either end. enquire_link every 30 to 60 seconds proves the socket is still usable in both directions. Miss the window the operator expects and the SMSC will drop you, usually at the least convenient moment.
Send enquire_link on the same bind you submit on, respond to the SMSC's own enquire_link promptly, and treat a missed response as a dead session: unbind, reconnect, resume. A client that keeps submitting into a half-open socket produces the classic incident where everything looks healthy and nothing is delivered.
How to size a window in practice
- Measure the round-trip time on the bind you actually use, not from your laptop. Submit a single message with a window of 1 and time submit_sm to submit_sm_resp.
- Divide your contracted TPS by the messages-per-second one round trip allows. That quotient is your minimum viable window.
- Add headroom — round up, then add roughly 50% — because round-trip time is not constant and a window sized exactly to the mean will underperform at the median.
- Raise it in steps, not in one jump, and watch for ESME_RTHROTTLED. The first sustained throttling tells you where the operator's limit really is.
- Confirm the value with the operator. Some SMSCs enforce a maximum window per bind and will silently queue or drop beyond it.
- Then, and only then, consider a second bind. Multiple binds multiply concurrency but also multiply the ways a session can fail.
When more binds beat a bigger window
There is a ceiling to what one TCP connection should carry. Beyond a few hundred outstanding PDUs the failure modes get unpleasant: a single socket problem takes all of that traffic with it, head-of-line blocking becomes visible, and recovery after a drop means replaying a large in-flight set. Most operators would rather see several moderate binds than one enormous one, and most will tell you the shape they prefer if asked.
Split by traffic class rather than arbitrarily. Keeping time-critical one-time passcodes on a separate bind from bulk marketing means a marketing burst cannot delay a login code — which is a throughput decision that is really a product decision.
A checklist before you blame the route
- What is the configured window, and did anyone set it deliberately?
- Are outstanding sequence numbers timed out and released?
- Is enquire_link running at the interval the operator expects?
- Are ESME_RTHROTTLED responses retried with backoff, or counted as failures?
- Is the measured round-trip time what you assumed when you sized the window?
- Are receipts (deliver_sm) being acknowledged promptly — an unacknowledged receipt stream can stall the session too?
Frequently asked questions
What is a good SMPP window size?
There is no universal number, because the right value is a function of your round-trip time and your contracted rate. A practical starting point for enterprise traffic is 10, then raise it until you either meet your contracted TPS or start seeing ESME_RTHROTTLED. Sizing it as contracted-TPS × round-trip-time, plus about 50% headroom, gets most integrations close on the first attempt.
Does a bigger window increase my throughput without limit?
No. Once the window is large enough that you reach the operator's rate limit, further increases produce ESME_RTHROTTLED rather than more messages. The window removes a concurrency bottleneck; it cannot raise a commercial ceiling.
Why does my throughput degrade over hours rather than immediately?
This is the classic symptom of outstanding sequence numbers that are never timed out. Every response that goes missing permanently consumes a window slot, so the effective window shrinks over the life of the session until the bind is behaving as if the window were 1. Releasing slots on a timeout fixes it.
Is ESME_RTHROTTLED a permanent failure?
No, and treating it as one loses traffic. It means you exceeded the agreed rate at that instant. Back off, retry the same message, and if throttling is sustained, reduce the window or renegotiate the rate.
How often should I send enquire_link?
Every 30 to 60 seconds is the usual band, and the exact figure should match what the operator expects. The purpose is to prove a possibly idle TCP session is still alive in both directions before a NAT or firewall silently discards it.
Should I use one large bind or several smaller ones?
Several moderate binds are generally healthier than one very large one, because a socket failure then costs you a fraction of your capacity rather than all of it. Split by traffic class — time-critical passcodes separately from bulk — so a marketing burst cannot delay a login code.
Does the window affect delivery receipts as well?
Yes, in the other direction. The SMSC pushes deliver_sm receipts to you, and if your application is slow to return deliver_sm_resp the receipt stream backs up. On a transceiver bind that congestion can affect the whole session, so acknowledge receipts promptly even if you process them asynchronously.
Where can I see this behaviour without connecting to a real SMSC?
Our SMPP session simulator walks a full SMPP 3.4 session in the browser — bind, submit, the asynchronous delivery receipt, enquire_link and unbind — and shows every PDU's fields mapped onto the actual octets on the wire. Nothing connects and nothing is sent.
The short version
If a bind is underperforming, check the window before you check the network. SMPP is asynchronous by design and most integrations run it synchronously by accident. Size the window from measured round-trip time and contracted rate, time out your sequence numbers, keep enquire_link honest, and retry ESME_RTHROTTLED with backoff. That sequence resolves the large majority of throughput complaints without anyone touching a route.
