
一个客户端准备上传较大的 POST 正文,服务端看完请求头就知道文件类型不收。默认写法已经把正文推上网络,拒绝响应只能省下后续处理,省不掉这次上传。HTTP 的 Expect: 100-continue 把这两段拆开:先发请求头,等服务器确认后再发送正文。
这不是所有请求都该加的优化。它多了一次往返;小请求通常不值得等。适用条件是正文够大、拒绝条件能从请求头判断,而且客户端和服务端都支持这个握手。
先把实验边界说清
实验只在本机用 Node.js 的 node:http 构造一个等价的客户端和服务端,不经过浏览器、代理或真实网络。客户端先送出带有 Expect 与 Content-Length 的头;收到 100 Continue 才调用 req.end(body)。服务端则在 checkContinue 事件中选择放行或拒绝。
这里用 11 字节字符串做正文,是为了看清先后顺序,不代表实际大文件的耗时收益。输出如下:
accepted status: 200
rejected status: 417
server events:
checkContinue /accept
body /accept 11 bytes
checkContinue /reject
接受路径里,服务端先处理 checkContinue /accept,然后才读到 11 字节正文。拒绝路径只有 checkContinue /reject,没有 body /reject。状态码 417 表示服务端拒绝这个期望;这里返回空响应体,避免把有没有传正文和错误消息长什么样混为一谈。日志只记录服务端收到的事件,因此应结合客户端回调判断脚本没有调用写正文的分支;单看服务端日志不足以推断请求在线路上的每个字节。若要在真实链路做结论,还得把代理行为纳入验证。实际上传时还需要设定等待上限,防止对端既不回 100 也不回最终状态,让请求悬在那里。
关键在 continue 事件
脚本中客户端的请求头包含 Expect: 100-continue。调用 flushHeaders() 是为了立即送出请求头,而不是等正文一起发送。正文只在客户端 continue 事件回调里写入:
req.on('continue', () => req.end(body));
req.flushHeaders();
服务端放行时调用 res.writeContinue(),它发出 100 中间响应,后续才读取请求正文。拒绝时直接发 417 并结束响应;没有发 100,客户端的回调就不会写正文。
不要把 100 Continue 当作最终响应。真正的业务结果仍是之后的 200、4xx 或 5xx。代码也要处理服务端直接返回最终状态的情况;客户端若只等 continue,就会把拒绝响应误当成请求一直没结束。本实验的响应回调专门接收最终状态码。
浏览器端不是同一套控制面
本机实验验证的是 Node 原生 HTTP 客户端行为,不等价于网页里的 fetch。浏览器脚本不能任意设置 Expect 这类受限制的请求头,也没有一个标准的 fetch 选项能让应用代码先拿到 100 再自行决定何时写入正文。因此,这条握手适合服务到服务的 HTTP 客户端或命令行上传器;不能据此推断浏览器上传页面也能直接采用同样流程。
我不会仅凭这个最小实验就推荐所有 Node 请求加上它。服务端如果必须读完正文才能判断是否接收,例如要校验内容本身,提前握手并不能避免上传;遇到代理时,还要单独确认中间层会转发并正确处理 Expect。这两点没有在本机实验里覆盖。
实际使用时,先确认请求库能暂停正文发送,再确认服务端与代理都处理 100-continue。拒绝条件能由请求头判定且正文很大时,这个多一次往返的协议才可能省下带宽;小正文、浏览器 fetch 或链路未经验证时,普通请求更简单。完整实验脚本保存在 tools/verify-expect-continue-upload.mjs,可用 node tools/verify-expect-continue-upload.mjs 重跑。