Jakarta vs Singapore PaaS Latency for Indonesian Users

Photo by yohanes budiyanto via Wikimedia Commons (CC BY 2.0)
Work it out rather than quoting a figure. Jakarta and Singapore are about 900 km apart, and at the roughly 200,000 km per second a signal travels in telecom fibre that sets a round-trip floor near nine milliseconds, which no real path reaches because cables are not straight and every peering handoff adds delay. Measure your own endpoint with curl write-out timings from the networks your users are actually on.
Because one cold HTTPS request is not one round trip. DNS, the TCP three-way handshake in RFC 9293, the TLS 1.3 handshake in RFC 8446 and finally the HTTP request each cost a round trip, so the difference is multiplied roughly fourfold before your handler even runs. If the response exceeds about 14 KB, the initial window of ten segments recommended by RFC 6928 adds another.
Use curl's write-out variables: time_namelookup, time_connect, time_appconnect, time_starttransfer and time_total. They are cumulative from the start of the transfer, so each leg is a subtraction — TLS costs time_appconnect minus time_connect, not time_appconnect itself. Add the --resolve option to keep a slow DNS resolver out of the network legs, and report the median of about twenty samples rather than the fastest one.
For static assets, largely yes: scripts, styles, fonts and images come from an edge near the user and the origin's location stops mattering on a cache hit. For uncached calls it changes nothing, because a POST is never cached and a personalised GET usually is not, so those requests still reach the origin. That uncached API path is the only part a region change actually moves.
When you need the provider's full managed-service catalogue, newer database engine versions, large-memory or GPU instance shapes, or deeper capacity and higher quota ceilings than an in-country region offers. Warm keep-alive traffic between your own services also feels the crossing far less than a cold mobile page load does. Splitting the deployment usually beats moving all of it.

Photo by yohanes budiyanto via Wikimedia Commons (CC BY 2.0)
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.
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.
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.
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.
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.
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.

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.
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.
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 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