Key Takeaway
Hosting an Indonesian product in Singapore puts roughly 900 km of mostly submarine fibre between server and user, and one cold HTTPS request pays that round trip four times: DNS, TCP, TLS and the request itself. Measure the legs with curl write-out timings before arguing about which region wins.
The Singapore region is rarely chosen. It is where the managed Postgres already lives, where the region dropdown lands by default, and where the tutorial told you to click. So Indonesian products end up served from another country by accident, and their users pay for that accident on every request, in milliseconds nobody ever put on a ticket.
This post is the arithmetic behind that decision, not a benchmark. I have deliberately not printed a round-trip time I did not measure. What follows is the physical floor you can compute from one cited constant, the reasons a real network path never reaches it, the round-trip count that multiplies whatever gap you do have, and the curl invocation that settles the argument in about a minute.
The sea crossing has a floor, and it is single-digit milliseconds
Before measuring anything, work out what the path cannot beat. A signal in telecommunications fibre travels at roughly 200,000 km per second, about two thirds of the speed of light in vacuum, because the doped-silica core has a refractive index near 1.4475. That constant is the one number in this post you never have to measure, and the only one no deployment decision can change.
# The floor comes from one cited constant: a signal in telecom fibre travels
# at about 200,000 km/s -- roughly two thirds of c, because the doped-silica
# core has a refractive index near 1.4475. Nothing in your stack beats it:
# not HTTP/3, not a bigger instance, not a faster language.
# Jakarta to Singapore is about 900 km great-circle. Real fibre is longer --
# cables follow shipping lanes and landing stations, not straight lines.
# Multiply BEFORE you divide, or bc truncates the whole thing to 0.0
echo "scale=1; 2 * 900 * 1000 / 200000" | bc
# 9.0 <- ms round trip, on a perfectly straight glass path
# Sanity-check the constant against the source's own worked example:
# Sydney to New York, 16,000 km, quoted as an 80 ms one-way minimum.
echo "scale=1; 16000 * 1000 / 200000" | bc
# 80.0 <- matches, so the constant is being used as intended
# Single-digit milliseconds is therefore the FLOOR for the sea crossing.
# It is not a target, and nothing you deploy will go below it.Jakarta and Singapore are about 900 km apart in a straight line, so a perfectly straight glass path would give a round trip of about nine milliseconds. No such path exists: submarine cable routes follow shipping lanes, landing stations and existing ducts, so the fibre a packet actually traverses is meaningfully longer than the great-circle line, and the real floor sits above nine milliseconds. Treat that figure as a lower bound you will never see rather than a target. Its value is that it tells you which order of magnitude the argument is about. This is a milliseconds problem, not a hundreds-of-milliseconds problem, and anything much larger than that in your measurement was added by something other than distance.
What you measure is routing, not distance
Once two endpoints are only 900 km apart, distance stops being the interesting variable and the path takes over. A packet leaving a phone in Bekasi and arriving at a load balancer in Singapore crosses several administrative boundaries, and each one is a place where somebody's capacity planning, peering policy or commercial agreement adds delay that has nothing to do with glass.
- Your user's access network. A home fibre tail behaves very differently from a mobile radio link, and the radio link adds delay of its own before the packet reaches the internet at all.
- The ISP's aggregation network and its choice of handoff. Two users in the same Jakarta neighbourhood on different retail ISPs can hand traffic off in very different places.
- A transit provider or an internet exchange. Traffic between two networks in the same city sometimes leaves the country and comes back, a pattern network engineers call tromboning, and a traceroute is the only way to find out whether yours does.
- The platform's own edge. A load balancer, a TLS terminator and possibly a service mesh hop all run before your handler sees the request, and none of them appear in the fibre calculation.
The practical consequence is about where you stand when you measure. A reading taken from your office fibre, or from a CI runner that happens to sit inside the same provider network as the endpoint, describes a path none of your users are on. If the decision matters, take the measurement from the access networks your analytics say your users actually use, and take it more than once.
One request is not one round trip
This is the part that turns a small difference into a large one, and the part most region arguments skip. A cold HTTPS request does not cross the sea once. Count the flights for a browser that has just been opened and has nothing cached.
- DNS. The client asks its resolver, and on a cold cache that resolver walks the delegation chain before it can answer. That is one round trip at best, often more, and the resolver is not necessarily anywhere near the user.
- TCP. RFC 9293 specifies the three-way handshake as SYN, SYN-ACK, ACK, and no application data may move until that third packet arrives. One round trip, with no way around it.
- TLS. RFC 8446 calls the ordinary TLS 1.3 exchange a 1-RTT handshake. If the client offers a key share group the server will not accept, section 2.1 has the server reply with HelloRetryRequest and the client start again with the right group — one whole extra round trip, spent on a guess.
- HTTP. Only now does the request go out and the first byte come back. One more round trip, plus however long your handler takes.
Then the leg nobody counts. RFC 6928 recommends an initial congestion window of ten segments capped at 14,600 bytes, so roughly the first 14 KB of a response arrives in one flight and everything after that waits for the window to grow. A 60 KB JSON list is not one round trip; it is several. Add it up: a cold request pays the crossing four times before the body even starts, and five or more times when the body is large. So a path that is ten milliseconds shorter is not worth ten milliseconds to your user. On a cold connection it is worth roughly four times that, which is why this argument is worth having at all.

Measure the legs with curl, do not argue about the region
curl already breaks a request into exactly the legs counted above, and its write-out variables expose every one of them. The single thing to get right is that those timers are cumulative from the start of the transfer, so each leg is a subtraction rather than a reading. Reporting time_appconnect as the cost of TLS is the most common way to misread this output, because that value also contains DNS and the TCP handshake.
# curl's write-out timers are CUMULATIVE from the start of the transfer, so
# every leg is a subtraction, not a reading. Missing that is why people report
# "TLS takes 300 ms" when they mean "everything up to and including TLS did".
cat > /tmp/curl-format.txt <<'EOF'
dns %{time_namelookup}
tcp_done %{time_connect}
tls_done %{time_appconnect}
req_sent %{time_pretransfer}
first_byte %{time_starttransfer}
total %{time_total}
EOF
# --resolve pins the address so a slow resolver stops polluting the network
# legs. -4 stops an IPv6 path and an IPv4 path being averaged together.
curl -sS -o /dev/null -w "@/tmp/curl-format.txt" \
--resolve api.example.com:443:203.0.113.10 \
-4 https://api.example.com/health
# Read the output as differences, in this order:
# time_namelookup DNS
# time_connect - time_namelookup TCP handshake, about 1 RTT
# time_appconnect - time_connect TLS handshake, 1 RTT on TLS 1.3
# time_starttransfer - time_appconnect request out, first byte back:
# 1 RTT PLUS your handler's time
# time_total - time_starttransfer the rest of the response body# One sample is noise. Twenty samples and a median is an argument.
# Report the median, not the minimum: the minimum is the moment the path
# happened to be empty, and no real user gets that moment.
for i in $(seq 1 20); do
curl -sS -o /dev/null -4 -w "%{time_connect}\n" \
https://api.example.com/health
done | sort -n | awk '{a[NR]=$1} END {print "median tcp:", a[int(NR/2)+1]}'
# time_connect on a fresh connection is DNS plus one round trip, so this is
# the cheapest honest proxy for RTT that also proves the port is reachable.
# ping is cheaper still, and measures something your application never does.Two flags make the numbers mean something. The --resolve option pins the address so a slow or distant resolver stops polluting the network legs you actually want to compare, and -4 keeps an IPv4 path and an IPv6 path from being averaged into a figure that describes neither. Then sample properly: one reading is noise, and the median of twenty is something you can put in a decision document.
Do not move a region on the strength of a ping. ping measures one round trip through the kernel, while your worst request makes at least four of them through a TLS terminator and an application. Worse, a ping from your own laptop on office fibre measures a path your users are not on. If you are willing to spend a migration on this, spend ten minutes first taking curl timings from the networks your analytics say your traffic comes from.
TLS 1.3 and keep-alive change the arithmetic completely
The four-round-trip figure is the cold case, and for a lot of traffic the cold case is rare. A connection that stays open pays DNS, TCP and TLS once and then costs one round trip per call. That is what a backend HTTP client with keep-alive enabled does all day, and it is why the same regional gap can be a serious problem for a first page load and almost irrelevant for a service calling a database in its own region.
# The same endpoint TWICE in one curl invocation. The second transfer reuses
# the open connection, so it pays neither the TCP nor the TLS handshake --
# which is what a keep-alive HTTP client in your backend does all day long.
curl -sS -o /dev/null -o /dev/null -4 \
-w "connects=%{num_connects} tls=%{time_appconnect} ttfb=%{time_starttransfer}\n" \
https://api.example.com/health \
https://api.example.com/health
# What to look for on the SECOND line: num_connects reports 0, because no new
# connection was opened, and time_appconnect stops pulling away from
# time_connect. The crossing is now paid once per call instead of four times.
#
# So measure both cases and label them. A cold mobile page load and a warm
# server-to-server call are two different latency stories, and quoting the
# cold number for warm traffic overstates what changing region would buy.TLS itself has two levers worth knowing. Session resumption skips the certificate exchange on reconnect, and RFC 8446 adds a 0-RTT mode that saves a round trip at connection setup for some application data — at a real cost, since that data has weaker security properties and can be replayed, so it is not the mechanism for a request that moves money. HTTP/3 folds transport and crypto setup together and multiplexes streams over one connection, which removes handshakes but not distance. None of these make the crossing shorter; they reduce how many times you pay for it.
Before comparing regions, check whether your own stack is even paying the cold price. Confirm keep-alive is on in every HTTP client you own, confirm HTTP/2 or HTTP/3 is negotiated at the edge, and confirm your API responses stay under about 14 KB where they can. Those three checks are free, they apply in either region, and they often recover more than a region change would.
Where Singapore still wins, and where in-country wins
Latency is one row in a table with several, and being honest about the rest is what makes the latency argument credible. Here is the comparison as I would put it to a team, with the rows where hosting in Indonesia genuinely wins kept separate from the rows where it does not.
| What you are choosing on | Hosted in Indonesia | Hosted in Singapore |
|---|---|---|
| Fibre floor to a Jakarta user | Metro fibre only, with no sea crossing to pay for | About nine milliseconds round trip at the theoretical best, higher in practice |
| One cold HTTPS request | Four metro round trips | Four sea crossings, so any gap is multiplied by four |
| A warm keep-alive API call | One metro round trip per call | One sea crossing per call, which is far less painful |
| Managed database catalogue | Fewer engines and fewer versions, so check the list before committing | The provider's full catalogue, usually the first region to get new engines |
| Capacity and instance shapes | Smaller pools, and large-memory or GPU shapes are often absent | Deep pools, more shapes, spot capacity, higher quota ceilings |
| Static assets behind a CDN | No measurable difference, the edge answers | No measurable difference, the edge answers |
| Uncached API calls | The only line a region change genuinely moves | Pays the crossing on every single call, forever |
Read rows four and five before rows one to three. A team that moves in-country and then discovers the managed Postgres version it needs is not offered has traded a real problem for a worse one. The pattern that survives contact with reality is usually a split: keep the parts that need the provider's deep catalogue where that catalogue is, and move the latency-sensitive request path — the API your mobile app calls on every screen — as close to the user as the platform allows.

A CDN buys the static half and none of the API half
A CDN in front of static assets erases most of the visible gap, and it is the cheapest thing on this list. Scripts, styles, fonts and images come from an edge near the user, the origin's location stops mattering on a cache hit, and the page starts painting before your server is involved at all. If your product is mostly content, stop reading here and go configure caching properly.
It does nothing for an uncached call. A POST is never cached and a personalised GET usually is not, so those requests still travel to the origin — and if you proxy them through the edge you have added a hop, because the user-to-edge leg now sits in front of an edge-to-origin leg that still crosses the sea. Whether that nets out depends entirely on whether the edge holds a warm pooled connection to your origin, which is the reuse argument again. The honest split is simple: cacheable traffic is a CDN problem, and uncached API traffic is the only thing a region choice can actually move.
The rule I would give a team is short. Count the round trips your worst request makes, measure each leg with curl write-out from the networks your users are on, and only then compare regions, because the physics sets a floor of a few milliseconds, the routing decides how far above it you land, and the round-trip count decides how much any of it matters. A CDN gets you the static half for nothing. The uncached API path is the only place a region change earns its migration.
Sources and further reading
- Wikipedia: Optical fiber — the refractive index of telecom silica and the resulting propagation speed of about 200,000 km per second
- RFC 9293 — Transmission Control Protocol, including the three-way handshake used to open a connection
- RFC 8446 — The Transport Layer Security Protocol Version 1.3: the 1-RTT handshake, HelloRetryRequest and 0-RTT mode
- RFC 6928 — Increasing TCP's Initial Window: ten segments, capped at 14,600 bytes
- curl manual page — the write-out timing variables, including time_appconnect, time_starttransfer and num_connects



