A form submission journey: 280 milliseconds from click to Slack
April 5, 2026 · 8 min read
A user fills in your contact form. They click Send. Two hundred and eighty milliseconds later, a notification lands in your team's Slack channel.
That number is roughly what our pipeline does at the median, on a Tuesday afternoon, for a user on a reasonable fiber connection in Western Europe. It's not a brag — there are services that go faster for narrower use cases — but it's a number I'm proud of, because getting there took work, and because most of the work was not the obvious parts.
I want to walk through what actually happens in those 280 milliseconds. This is partly an engineering post and partly a look under the hood of a system most people treat as a black box. If you're the kind of person who likes seeing how the sausage is made, grab a coffee.
Here we go.
t = 0 ms — The click
The user presses Enter, or taps the submit button. The browser begins assembling the POST. This is nearly instant — the serialization of a small contact form payload (name, email, message, maybe a few hidden fields) takes a handful of microseconds. Negligible.
What matters at this step is what the form looks like in the DOM. If the <form> tag is a native HTML form with action="https://api.formto.dev/f/...", the browser handles the rest natively. If the form is wrapped in a JavaScript framework that intercepts the submit event, we add 5–30ms of overhead depending on how much the framework wants to do first. This is one reason I wrote the manifesto in defense of plain HTML forms — every millisecond you save at this step is a millisecond the user experiences as "snappy."
Running total: 0 ms
t = 0 → 35 ms — DNS and TCP to the edge
The browser resolves api.formto.dev. If the DNS record is cached (which it usually is after the first request to the site), this is zero. If not, it's 10–30ms depending on the user's resolver.
Then the browser opens a TCP connection to our edge. We run on an anycast network, which means the user's packets hit the closest edge node automatically. For a user in Amsterdam, that's a node in Amsterdam. For a user in Sydney, it's a node in Sydney. The handshake takes about 20ms at this range.
If TLS 1.3 is in play (it is, everywhere), the handshake is 1-RTT — one round trip to negotiate encryption. That's another 20ms at typical distances.
With HTTP/2 keep-alive, a user who already loaded the contact page has most of this cached. For a fresh visitor from a cold cache, the total TCP+TLS setup is ~35ms.
Running total: ~35 ms
t = 35 → 60 ms — Edge validation
The edge node receives the POST. Before it does anything else, it runs a short pipeline of cheap, strict checks:
- Rate limit check. Is this IP within the per-form and per-IP limits? This is a single lookup in an in-memory counter. ~0.5ms.
- Schema shape check. Does the payload have the expected fields for this form? No heavy validation — just "is this a form submission and not a malformed request?" ~1ms.
- Auth check for the form slug. Does
your-form-slugexist, is it active, and does it accept submissions from this origin? We cache form configs at the edge, so this is a memory lookup. ~1ms. - Early spam signals. Honeypot field empty? Submission time reasonable? Missing
Accept-Language? Suspicious user agent? These checks run in a single pass in about 2ms.
If any of these fail fast, the request is rejected at the edge without ever touching the origin. This is crucial for abuse traffic — we don't want to pay the cost of crossing the continent to drop a submission.
If the request passes, it's forwarded to the nearest origin region. For an Amsterdam user, that's our EU cluster, ~5ms away.
Running total: ~65 ms
t = 65 → 120 ms — Ingestion and storage
The origin receives the submission. Here's what happens next, in order:
Parse and canonicalize. The payload is parsed into a canonical internal format. ~1ms.
Spam scoring. A second, heavier pass of spam checks. Content heuristics, reputation lookups for the IP in our own database (not a third-party), cross-form pattern matching. This runs in about 8ms on the median, longer on the tail.
Store. The submission is inserted into our primary database — Postgres, on EU-region infrastructure. The insert is small (a few KB at most) and uses a prepared statement with minimal indexing overhead. ~4ms.
Acknowledge. The submission ID is generated and the API returns a 200 to the user's browser immediately after storage. The user sees the thank-you redirect. From the user's perspective, the form has succeeded.
Running total (user-facing): ~120 ms
This is the moment that matters for the user. From click to "we got it," the happy path is around 120ms. That's faster than a keystroke registering in a text editor. The user experiences it as instant.
Everything that happens after this runs in the background. The user has already moved on.
t = 120 → 150 ms — Enqueue for delivery
The stored submission is enqueued for downstream delivery. This is a fast operation — push to an in-process queue, with durability backed by Postgres. ~3ms.
The delivery worker picks it up almost immediately. We size our worker pool to keep median pickup latency under 5ms. On a busy day the tail stretches to 20–50ms; on a slow day it's essentially zero.
Running total: ~150 ms
t = 150 → 200 ms — Email notification fire-and-forget
If the form has email notifications enabled, an email is composed and handed off to our transactional email provider. We don't wait for the provider to confirm delivery — we hand off, log the correlation ID, and continue. ~30ms for the HTTP handoff, which happens in parallel with everything else.
Email delivery itself takes seconds to minutes and is not on the critical path for the Slack notification we're timing. I include it here because it's part of "what happens during those 280ms" — we've dispatched the email by the 200ms mark, even though it arrives later.
Running total: ~200 ms
t = 200 → 260 ms — Webhook delivery to Slack
This is the step everyone cares about and the one that used to be our longest.
The delivery worker formats the payload for Slack — turning the raw submission into a human-readable message with field names, values, and a link back to the dashboard. ~3ms of templating.
Then it POSTs to Slack's incoming webhook URL. Slack's webhook endpoint is on AWS in us-east-1. From our EU worker to AWS us-east-1 is ~80ms round trip at the network layer. Slack's own response time adds another ~30ms on the median.
Total webhook round trip: ~55–65ms when everything is warm. We pre-warm connections to Slack's endpoints for known form destinations, which shaves another ~15ms off the cold-start cost.
Running total: ~260 ms
t = 260 → 280 ms — Slack renders the message
The webhook returns success. Slack's internal pipeline processes the incoming payload, routes it to the correct channel, and pushes it to every connected client via their real-time messaging layer. For users with the Slack desktop app open, the message appears in ~15–20ms after the webhook acknowledgment.
The notification chimes. Someone on your team reads it.
Running total: ~280 ms — Slack ping delivered
Where we cut time (the unobvious places)
The obvious optimizations — anycast edges, persistent TLS, HTTP/2 keep-alive — are table stakes. Everything we do competitively happens in places that look boring from the outside.
Pre-warmed connections to common webhook destinations. Slack, Discord, n8n Cloud, and a handful of others have their endpoints pre-dialled so the first byte of our POST hits their TCP stack without a handshake. This alone cut ~15–20ms off the tail.
Form config caching at the edge. Every form's spam rules, redirect URL, webhook destination, and notification settings are cached at the edge with a short invalidation window. No origin round-trip to look up "where does this submission go." This cut another ~30ms.
Parallelism on downstream. Email, webhook, and any additional integrations fire in parallel, not serially. Nobody waits for nobody. This mostly helps when customers have multiple downstream destinations, but even in the single-Slack case it means email dispatch does not add to webhook latency.
Rejecting abuse at the edge, not at the origin. A sustained attack on a form used to slow the pipeline for legitimate submissions because the worker pool was busy. Moving early rejection to the edge fixed that — legitimate submissions always have a free worker waiting.
Boring databases with boring indexes. Postgres with a simple schema, good indexes, and connection pooling will outrun clever distributed systems for writes of this shape all day long. Half our optimization work was not adding exotic infrastructure.
Choosing the right region. Storing EU submissions in the EU is a compliance feature, but it's also a latency feature. Keeping the data geographically close to the form endpoint keeps the whole pipeline inside one continent's network topology.
Where the variance lives
280ms is the median on a good day. The tail is longer. Here's where it stretches:
- Slack on an off day can add 200–500ms to the webhook round trip. Not often, but sometimes.
- A cold TLS handshake from a user on a low-quality mobile connection can add 150ms just to the setup.
- An email provider rate-limiting us during a burst would delay the email hand-off, though we've tuned our retry policy so it doesn't block the webhook.
- A huge payload (someone pastes a 40KB message) can add 5–10ms to parsing and storage. Not a real problem in practice.
We publish tail latency percentiles internally. I'm not going to bore you with them. The short version: p99 is under 900ms on a normal week, which means even the slowest 1% of submissions reach Slack in under a second.
The human version of this number
Why does any of this matter? Honestly, from the user's perspective, 280ms is indistinguishable from 600ms. Both feel instant. The form works. They see the thank-you page. They go on with their day.
The number matters for the team. When the Slack ping arrives 280ms after the click, you can be mid-conversation with the person. You can reply in the same minute they submitted. You can reach the high-intent moment where they're still at their desk, still in the mood, still wondering if you'll answer. A 4-second webhook delay sounds negligible until you compare the reply rate between a team that responds to pings in the same minute and a team that responds the next morning. The response time teardown has some numbers on that; they are not close.
Fast webhook delivery is not a performance flex. It's a conversation-enabling feature. That's what I care about.
If you want your contact form to reach Slack in a few hundred milliseconds without having to build any of the above, FormTo does it on every plan, free included. Paste a Slack incoming webhook URL, point your form at us, and watch the pings arrive before your coffee refills.
Adjacent reading: the 3 a.m. incident (what happens when this pipeline is under attack), how forms die (what happens when one of these steps quietly fails), and the boring HTML form (why the first few milliseconds matter more than you think).
More posts