Two identical services, one 110 KB page: 110 ms on one, 500 ms on the other
tikitko
FREEOP

19 days ago

Two services in one project: same image, same database, byte-identical 110 KB front page.

Template: https://railway.com/deploy/rust-blog

Both are public and reproduce at will:

blog-test1.up.railway.app

blog-test2.up.railway.app

INTERLEAVED REQUESTS, SECONDS APART

blog-test1 ttfb=0.115 size=109889

blog-test2 ttfb=0.498 size=109889

blog-test1 ttfb=0.103 size=109889

blog-test2 ttfb=0.508 size=109889

blog-test1 ttfb=0.174 size=109889

blog-test2 ttfb=0.510 size=109889

Nine consecutive pairs, no overlap.

The app reports its own render time in a Server-Timing header, and the slow service is the one doing less work: blog-test2 builds the page in 24-39 ms, blog-test1 in 49-70 ms.

SIZE THRESHOLD

One warmed connection, so no DNS/TCP/TLS in the numbers:

146 B — 0.055 0.056 0.058 0.061 0.063

46,909 B — 0.055 0.056 0.059 0.061 0.064

83,208 B — 0.054 0.055 0.056 0.056 0.102

109,889 B — 0.475 0.481 0.481 0.522 0.532

The boundary sits between 83,208 and 109,889 bytes here; on an earlier stand with finer steps, between 82,291 and 88,524.

The penalty is flat — the same 250-430 ms at 88 KB as at 2.1 MB.

STATIC FILES ARE EXEMPT

nginx serves assets with try_files; / and /api/* go through proxy_pass. Same container, same connection, same curl invocation:

favicon.ico — 15,406 B — ttfb 0.032 — total 0.032

blog-ui-*.js — 28,921 B — ttfb 0.033 — total 0.034

blog-ui-*.wasm — 1,236,529 B — ttfb 0.032 — total 0.082

/ — 109,889 B — ttfb 0.478 — total 0.502

1.24 MB is delivered in full before the 110 KB page emits a first byte.

On penalised requests the body lands within 4-25 ms of the first byte. Nothing transfers slowly — the response is held, then released all at once.

RULED OUT

Payload size — flat penalty from 88 KB to 2.1 MB.

Bandwidth — 1.24 MB in 82 ms on the same connection.

Compression — with gzip the page is ~25 KB on the wire, still 297-526 ms.

nginx buffers — proxy_buffers 16 32k + proxy_max_temp_file_size 0, no change.

nginx listen — listen [::]:$PORT ipv6only=off, no change.

CPU — 4 vCPU, nr_throttled 0 during the stall.

Cloudflare — reproduces on the bare *.up.railway.app domain.

Data shape — 5, 13, 21, 26 rows; bodies 800 B to 200 KB.

From a shell inside the container, the same response is produced in under 100 ms whether or not the external request is penalised.

INTERMITTENT

blog-test1 was penalised on eight consecutive requests earlier today at a similar size, and is clean now — no redeploy, restart or config change in between. blog-test2 has been penalised throughout.

The state follows the container, holds for tens of minutes, and flips on its own.

QUESTION

  1. Is there a buffering layer for proxied responses above ~80-90 KB?
  2. Does anything determine whether a container lands in that mode?
  3. Is anything configurable to avoid it?
$10 Bounty

10 Replies

Railway
BOT

19 days ago

This thread has been opened as a bounty so the community can help solve it.

Status changed to Open Railway 19 days ago


devmedical
PRO

19 days ago

Independent confirmation from a completely different stack (Django + gunicorn, no nginx) — we spent today chasing what looks like the same edge behavior, from the streaming angle: https://station.railway.com/questions/edge-proxy-buffers-streaming-responses-4084b96c

Our findings, which complement yours:

  • For Server-Sent Events (chunked, text/event-stream), the edge holds the ENTIRE response and releases it only at EOF — a diagnostic endpoint emitting 10 chunks at 500ms intervals arrives as a single burst after ~5s. That matches your "held, then released all at once" observation, but with no size threshold at all: even a 1MB leading payload is held until the stream closes.
  • Immune to everything we threw at it: Cache-Control: no-cache, no-transform; X-Accel-Buffering: no; GET vs POST; compression on/off (identical with and without Accept-Encoding). Cloudflare ruled out (reproduces on the bare *.up.railway.app domain, same as you).
  • From inside the container, localhost responds perfectly (progressive chunks every 50-100ms) — the hold is added between the container and the public edge. Matches your in-container finding.

So there seem to be (at least) two manifestations of one buffering layer in the proxied-response path: a flat ~250-430ms penalty for non-streaming bodies above ~85KB (your case), and hold-until-EOF for streaming responses of any size (ours).

One actionable idea for your intermittency ("state follows the container, flips on its own"): capture the response headers / request IDs on penalized vs clean episodes (x-railway-request-id or any edge identifier) and compare — if the penalized state correlates with which edge replica the container is routed through, that would explain why one service is clean and the identical one isn't, and give the Railway team a precise place to look.


ahmed12dev
FREE

19 days ago

We've isolated the issue as much as possible, and the evidence suggests the delay is occurring upstream of our application.

The application consistently generates responses in under 100 ms (confirmed both via Server-Timing and direct requests from inside the container). The additional 250–500 ms delay only appears for proxied dynamic responses above approximately 80–90 KB, while static files—even those over 1 MB—are delivered immediately.

The behavior is intermittent and appears to follow a specific container rather than the application or deployment. Since the penalty remains constant regardless of payload size or compression, it does not appear to be a bandwidth or application performance issue.

Could you confirm:

Is there any response buffering or optimization layer at the Railway edge for proxied HTTP responses?

Is the ~80–90 KB threshold expected behavior or a known issue?

Why would one container consistently exhibit this behavior while another identical container does not?

Is there any configuration or routing option that disables this behavior or forces immediate streaming of proxied responses?


tikitko
FREEOP

19 days ago

Note: hitting "Redeploy" a few times usually helps — roughly 1 deployment in 5 comes out clean. So for now my workaround is to keep redeploying until I win ;D

https://railway.com/project/2061e25b-4a79-4f96-92ce-3d969c4768db

Some additional info:


INSIDE https://blog-test1.up.railway.app/

root@5367cc3fc971:/app# echo "PORT=$PORT"; for i in $(seq 1 10); do curl -s -o /dev/null -w "app ttfb=%{time_starttransfer} size=%{size_download}\n" "http://127.0.0.1:3000/"; curl -s -o /dev/null -w "nginx ttfb=%{time_starttransfer} size=%{size_download}\n" "http://127.0.0.1:$PORT/"; done

PORT=8080

app ttfb=0.058692 size=109889

nginx ttfb=0.052770 size=109889

app ttfb=0.050843 size=109889

nginx ttfb=0.052975 size=109889

app ttfb=0.054039 size=109889

nginx ttfb=0.053825 size=109889

app ttfb=0.051657 size=109889

nginx ttfb=0.048656 size=109889

app ttfb=0.052321 size=109889

nginx ttfb=0.053943 size=109889

app ttfb=0.053275 size=109889

nginx ttfb=0.056426 size=109889

app ttfb=0.064111 size=109889

nginx ttfb=0.049354 size=109889

app ttfb=0.050858 size=109889

nginx ttfb=0.055881 size=109889

app ttfb=0.048620 size=109889

nginx ttfb=0.050532 size=109889

app ttfb=0.052489 size=109889

nginx ttfb=0.053361 size=109889

OUTSIDE https://blog-test1.up.railway.app/

tikitko@MacBook-Pro-Nikita ~ % h=blog-test1.up.railway.app; for i in $(seq 1 10); do curl -s --http1.1 -w "ttfb=%{time_starttransfer} size=%{size_download}\n" -o /dev/null "https://$h/w-$RANDOM.js" -o /dev/null "https://$h/" | tail -1; done

ttfb=0.086309 size=109889

ttfb=0.133939 size=109889

ttfb=0.091684 size=109889

ttfb=0.084978 size=109889

ttfb=0.080868 size=109889

ttfb=0.080094 size=109889

ttfb=0.112140 size=109889

ttfb=0.081164 size=109889

ttfb=0.084543 size=109889

ttfb=0.080615 size=109889


INSIDE https://blog-test2.up.railway.app/

root@eb0e09ea0761:/app# echo "PORT=$PORT"; for i in $(seq 1 10); do curl -s -o /dev/null -w "app ttfb=%{time_starttransfer} size=%{size_download}\n" "http://127.0.0.1:3000/"; curl -s -o /dev/null -w "nginx ttfb=%{time_starttransfer} size=%{size_download}\n" "http://127.0.0.1:$PORT/"; done

PORT=8080

app ttfb=0.027741 size=109889

nginx ttfb=0.021462 size=109889

app ttfb=0.024064 size=109889

nginx ttfb=0.022877 size=109889

app ttfb=0.020958 size=109889

nginx ttfb=0.020556 size=109889

app ttfb=0.022519 size=109889

nginx ttfb=0.025755 size=109889

app ttfb=0.027148 size=109889

nginx ttfb=0.024757 size=109889

app ttfb=0.022692 size=109889

nginx ttfb=0.024048 size=109889

app ttfb=0.023715 size=109889

nginx ttfb=0.021974 size=109889

app ttfb=0.022836 size=109889

nginx ttfb=0.022434 size=109889

app ttfb=0.025861 size=109889

nginx ttfb=0.030469 size=109889

app ttfb=0.024980 size=109889

nginx ttfb=0.023474 size=109889

OUTSIDE https://blog-test2.up.railway.app/

tikitko@MacBook-Pro-Nikita ~ % h=blog-test2.up.railway.app; for i in $(seq 1 10); do curl -s --http1.1 -w "ttfb=%{time_starttransfer} size=%{size_download}\n" -o /dev/null "https://$h/w-$RANDOM.js" -o /dev/null "https://$h/" | tail -1; done

ttfb=0.578564 size=109889

ttfb=0.477331 size=109889

ttfb=0.542685 size=109889

ttfb=0.488595 size=109889

ttfb=0.479241 size=109889

ttfb=0.572960 size=109889

ttfb=0.504554 size=109889

ttfb=0.544505 size=109889

ttfb=0.483017 size=109889

ttfb=0.481072 size=109889


tikitko
FREEOP

18 days ago

up


tikitko
FREEOP

17 days ago

Any ideas here?


manuproject
HOBBY

17 days ago

The issue is that Railway's edge router and your container's port are racing during startup, causing a flaky proxy tunnel about 4 out of 5 times.

To fix it, you need to make sure your app or Nginx is fully up and listening on the port before the container finishes starting, or add a slight startup delay.

Try wrapping your start command in a small script that verifies the port is active before letting the container exit or finish initializing, like this:


tikitko

Any ideas here?

manuproject
HOBBY

17 days ago

can you send the current image or error displaying this was in my draft since yesterday


manuproject

The issue is that Railway's edge router and your container's port are racing during startup, causing a flaky proxy tunnel about 4 out of 5 times. To fix it, you need to make sure your app or Nginx is fully up and listening on the port before the container finishes starting, or add a slight startup delay. Try wrapping your start command in a small script that verifies the port is active before letting the container exit or finish initializing, like this:

tikitko
FREEOP

17 days ago

thanks for the answer! I think you missed the example part in your comment. Also, then why do static files work fine?


tikitko

thanks for the answer! I think you missed the example part in your comment. Also, then why do static files work fine?

manuproject
HOBBY

17 days ago

You're right, my original theory doesn't hold up against this — your local tests show the app and nginx are both fast internally regardless of whether the same request gets penalized externally, so it's not a startup race or anything in your container at all.

This looks like it's happening in Railway's edge/proxy layer, not your code. Two things stand out: the response is fully buffered then released all at once (not slow — held), and there's a hard size threshold around 85-90KB where it flips on. That pattern plus the fact that it follows the container and flips on its own over time points to something at the edge, maybe size-based routing or some kind of response inspection, not resource limits — you've already ruled those out.


tikitko
FREEOP

16 days ago

Railway team, any ideas here?


Welcome!

Sign in to your Railway account to join the conversation.

Loading...