Some differences between web servers
Changelog
- [2026-01-15 Thu] First draft created
- [2026-01-19 Mon] First version released
The Crux
What should a web server do if a request matches the path but doesn't match the HTTP verb (i.e., request method)?
There are multiple answers to this question, and in this article, I'm going to present some examples.
- You can find the code used in this GitHub repository: 64J0/simple-servers.
Giraffe
Let's start considering the Giraffe router.
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Giraffe
let endpoints = choose [ GET >=> route "/hello" >=> text "Hello from Giraffe!" ]
let configureApp (appBuilder: IApplicationBuilder) = appBuilder.UseGiraffe(endpoints)
let configureServices (services: IServiceCollection) = services.AddGiraffe() |> ignore
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
configureServices builder.Services
let app = builder.Build()
configureApp app
app.Run()
0
This is what you get after making different requests:
GET /hello
curl -s -S -v -X GET http://localhost:5000/hello
# ...
< HTTP/1.1 200 OK
< Content-Length: 19
< Content-Type: text/plain; charset=utf-8
< Date: Tue, 20 Jan 2026 01:27:47 GMT
< Server: Kestrel
<
* Connection #0 to host localhost left intact
Hello from Giraffe!
GET /notfound
curl -s -S -v -X GET http://localhost:5000/notfound
# ...
< HTTP/1.1 404 Not Found
< Content-Length: 0
< Date: Tue, 20 Jan 2026 20:06:41 GMT
< Server: Kestrel
<
* Connection #0 to host localhost left intact
POST /hello
curl -s -S -v -X POST http://localhost:5000/hello
# ...
< HTTP/1.1 404 Not Found
< Content-Length: 0
< Date: Tue, 20 Jan 2026 20:06:40 GMT
< Server: Kestrel
<
* Connection #0 to host localhost left intact
Not Found
Giraffe With EndpointRouting and ASP.NET Core
Now, let's check what happens if we use the Giraffe EndpointRouting:
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Giraffe
open Giraffe.EndpointRouting
let endpoints =
[ GET [ route "/hello" (text "Hello from Giraffe with Endpoint Routing!") ] ]
let configureApp (appBuilder: IApplicationBuilder) =
appBuilder.UseRouting().UseGiraffe(endpoints) |> ignore
let configureServices (services: IServiceCollection) =
services.AddRouting().AddGiraffe() |> ignore
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
configureServices builder.Services
let app = builder.Build()
configureApp app
app.Run()
0
And this is what we get after making the same requests using curl:
GET /hello
curl -s -S -v -X GET http://localhost:5000/hello
# ...
< HTTP/1.1 200 OK
< Content-Length: 41
< Content-Type: text/plain; charset=utf-8
< Date: Tue, 20 Jan 2026 01:28:03 GMT
< Server: Kestrel
<
* Connection #0 to host localhost left intact
Hello from Giraffe with Endpoint Routing!
GET /notfound
curl -s -S -v -X GET http://localhost:5000/notfound
# ...
< HTTP/1.1 404 Not Found
< Content-Length: 0
< Date: Tue, 20 Jan 2026 20:06:59 GMT
< Server: Kestrel
<
* Connection #0 to host localhost left intact
POST /hello
curl -s -S -v -X POST http://localhost:5000/hello
# ...
< HTTP/1.1 405 Method Not Allowed
< Content-Length: 0
< Date: Tue, 20 Jan 2026 20:06:59 GMT
< Server: Kestrel
< Allow: GET
<
* Connection #0 to host localhost left intact
There's an interesting difference here.
Using Giraffe EndpointRouting, if our request matches the path but doesn't match the HTTP verb, our server returns a 405 Method Not Allowed status code, and a new header is added to instruct the client which HTTP methods are available: Allow: GET.
Note that this is consistent with the text in the RFC 7231 that defines the Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content:
6.5.5. 405 Method Not Allowed
The 405 (Method Not Allowed) status code indicates that the method received in the request-line is known by the origin server but not supported by the target resource. The origin server MUST generate an Allow header field in a 405 response containing a list of the target resource's currently supported methods.
A 405 response is cacheable by default; i.e., unless otherwise indicated by the method definition or explicit cache controls [...].
In fact, this behavior is inherited from the ASP.NET Core router, which is actually used under the hood for the Giraffe EndpointRouting.
You can also try with a simple minimal API:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/hello", () => "Hello from ASP.NET Core Minimal API!");
app.Run();
Where we get a similar response from the server.
Express.js
Now, instead of .NET, let's consider Express.js, which is another web server technology I have experience with.
This is the code I'm going to use:
const express = require('express')
const app = express()
const port = 5000
app.get('/hello', (_req, res) => {
res.send('Hello from Express!')
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
And these are the responses I get:
GET /hello
curl -s -S -v -X GET http://localhost:5000/hello
# ...
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: text/html; charset=utf-8
< Content-Length: 19
< ETag: W/"13-tsbq4e7agwVV6r9iE+Lb/lLwlzw"
< Date: Tue, 20 Jan 2026 01:27:31 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Connection #0 to host localhost left intact
Hello from Express!
GET /notfound
curl -s -S -v -X GET http://localhost:5000/notfound
# ...
< HTTP/1.1 404 Not Found
< X-Powered-By: Express
< Content-Security-Policy: default-src 'none'
< X-Content-Type-Options: nosniff
< Content-Type: text/html; charset=utf-8
< Content-Length: 147
< Date: Tue, 20 Jan 2026 01:27:32 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Connection #0 to host localhost left intact
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /notfound</pre>
</body>
</html>
POST /hello
curl -s -S -v -X POST http://localhost:5000/hello
# ...
< HTTP/1.1 404 Not Found
< X-Powered-By: Express
< Content-Security-Policy: default-src 'none'
< X-Content-Type-Options: nosniff
< Content-Type: text/html; charset=utf-8
< Content-Length: 145
< Date: Tue, 20 Jan 2026 01:27:32 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Connection #0 to host localhost left intact
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot POST /hello</pre>
</body>
</html>
Notice that the headers are different, and Express returns an HTML page by default for the not found error.
More differences
Another interesting behavior is that Express.js can't handle HTTP verbs if they're not all uppercase.
For example, consider this request:
curl -s -S -v -X get http://localhost:5000/hello
# ...
>
< HTTP/1.1 400 Bad Request
< Connection: close
<
* Closing connection
This doesn't happen with ASP.NET and Giraffe; for example, they work with all these variations: GET, Get, and get.
Gin
Finally, I decided to check what happens in a Web API built using a popular Go library named Gin. The code used was:
package main
// go run main.go
import (
"github.com/gin-gonic/gin"
)
func main() {
// 1. Create a default Gin router with logging and recovery middleware
r := gin.Default()
// 2. Define the GET /hello endpoint
r.GET("/hello", func(c *gin.Context) {
// Returns a plain string with a 200 OK status
c.String(200, "Hello from Gin (Go lang)!")
})
// 3. Run the server on port 5000
r.Run(":5000")
}
And these are the responses:
GET /hello
curl -s -S -v -X GET http://localhost:5000/hello
# ...
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Date: Tue, 20 Jan 2026 01:28:22 GMT
< Content-Length: 25
<
* Connection #0 to host localhost left intact
Hello from Gin (Go lang)!
GET /notfound
curl -s -S -v -X GET http://localhost:5000/notfound
# ...
< HTTP/1.1 404 Not Found
< Content-Type: text/plain
< Date: Tue, 20 Jan 2026 01:28:23 GMT
< Content-Length: 18
<
* Connection #0 to host localhost left intact
404 page not found
POST /hello
curl -s -S -v -X POST http://localhost:5000/hello
# ...
< HTTP/1.1 404 Not Found
< Content-Type: text/plain
< Date: Tue, 20 Jan 2026 01:28:23 GMT
< Content-Length: 18
<
* Connection #0 to host localhost left intact
404 page not found
More differences
Again, when using Gin, the server can't handle HTTP verbs if they're not all uppercase, as is the case with Express.js.
Conclusion
There's no consensus for this implementation; therefore, you need to check what the behavior of the tool you're using is. Different web server frameworks handle mismatched HTTP verbs in different ways, so it's important to understand how your chosen technology behaves in these scenarios.