{"id":25460,"date":"2026-08-30T09:08:04","date_gmt":"2026-08-30T09:08:04","guid":{"rendered":"https:\/\/capskip.com\/?p=25460"},"modified":"2026-08-30T09:08:04","modified_gmt":"2026-08-30T09:08:04","slug":"k6-captcha-load-test","status":"publish","type":"post","link":"https:\/\/capskip.com\/zh\/k6-captcha-load-test\/","title":{"rendered":"\u5982\u4f55\u7528\u539f\u59cb API \u5728 k6 \u8d1f\u8f7d\u6d4b\u8bd5\u4e2d\u8bc6\u522b\u9a8c\u8bc1\u7801"},"content":{"rendered":"<p>A k6 captcha step has to go through the raw HTTP API, because k6 is not Node and the SDK cannot be installed into it. That is the easy part: two calls, submit and poll. The part that decides whether your test is worth anything is where the solve runs. Put it in the default function and every virtual user solves on every iteration, which measures the solver instead of your application and floods a machine that was never meant to take that. Put it in the setup stage and you get clean numbers, with one honest limit you need to know about.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What you need<\/h2>\n<ul>\n<li>k6, any recent version. No packages to install, because there is nothing to install into.<\/li>\n<li>The sitekey and the page URL of the protected endpoint you are testing.<\/li>\n<li>CapSkip running in Local mode if k6 runs on the same machine as the solver, or in Server mode if it runs on a load generator or in CI. Both are described under <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a>.<\/li>\n<\/ul>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">When solving is the right call<\/h2>\n<p>Say this plainly before writing any code. If the site under test is yours, the better move is usually to let the load generator past the challenge: allowlist its address, or send a header your staging environment recognises, and skip the widget entirely. You are trying to measure your application, and every solved challenge adds latency that belongs to somebody else.<\/p>\n<p>Solving is the right call in two cases. The endpoint you need to hit is protected and you do not control that protection, or the protected path itself is the thing under test and skipping it would test a route real traffic never takes. Both are real, and both are what the rest of this post is for.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Why the SDK does not work here<\/h2>\n<p>k6 scripts look like JavaScript and are not running on Node. The require implementation is k6&#8217;s own, and the documentation is blunt about the limit: it loads built-in k6 modules, local files and remote scripts, and it does not support the Node module resolution algorithm. No npm, no node_modules, no fs and no crypto. So the CapSkip package cannot be imported, and neither can anything else you might reach for.<\/p>\n<p>This costs less than it sounds. The API is 2captcha compatible and has two endpoints, so k6&#8217;s own http module covers it in about fifteen lines. If your load generator turns out to be a plain Node process after all, <a href=\"https:\/\/capskip.com\/nodejs-captcha-solver\/\">the Node.js CAPTCHA solver page<\/a> covers the client that does have an SDK.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 1: write the solve as a plain function<\/h2>\n<p>Submit to in.php, get an ID, then poll res.php until the answer arrives. Ask for JSON so you are reading fields instead of splitting strings on a pipe character.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">\/\/ No install step. Both modules are built into k6.\nimport http from 'k6\/http';\nimport { sleep } from 'k6';\n\nconst SOLVER = 'http:\/\/127.0.0.1:8080';\nconst KEY = 'YOUR_API_KEY';\n\nfunction solve(sitekey, pageurl) {\n  const submitted = http.post(SOLVER + '\/in.php', {\n    key: KEY,\n    method: 'userrecaptcha',\n    googlekey: sitekey,\n    pageurl: pageurl,\n    json: '1',\n  });\n\n  return poll(submitted.json('request'));   \/\/ the captcha ID\n}<\/pre>\n<\/div>\n<p>Note the parameter name. reCAPTCHA wants googlekey, while Turnstile wants sitekey with the turnstile method. Sending the wrong one is the usual cause of an ERROR_GOOGLEKEY response.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">function poll(id) {\n  const url = SOLVER + '\/res.php?key=' + KEY +\n              '&amp;action=get&amp;json=1&amp;id=' + id;\n\n  \/\/ Roughly three minutes of headroom at five seconds a try.\n  for (let i = 0; i &lt; 36; i++) {\n    sleep(5);\n    const res = http.get(url);\n    if (res.json('status') === 1) {\n      return res.json('request');   \/\/ the token\n    }\n  }\n  throw new Error('solve did not finish in time');\n}<\/pre>\n<\/div>\n<p>A pending answer comes back as CAPCHA_NOT_READY with a status of zero, which is why the loop checks the status field rather than treating any response as done. Results are readable once, so keep the token when you get it.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 2: solve in the setup stage<\/h2>\n<p>k6 runs setup once, before any virtual user starts, and passes whatever it returns into the default function. That is exactly the shape this needs. Solve there, hand the tokens out, and no VU pays for a solve inside its own iteration.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">const PAGE = 'https:\/\/example.com\/page-with-recaptcha';\nconst SITEKEY = 'YOUR_SITEKEY';\n\nexport const options = {\n  vus: 10,\n  duration: '90s',\n  setupTimeout: '5m',   \/\/ the 60s default expires mid solve\n};\n\nexport function setup() {\n  \/\/ One token per VU. They are single use.\n  const tokens = [];\n  for (let i = 0; i &lt; 10; i++) {\n    tokens.push(solve(SITEKEY, PAGE));\n  }\n  return { tokens };\n}<\/pre>\n<\/div>\n<p>The setupTimeout line matters more than it looks. k6 gives setup 60 seconds by default, and a single reCAPTCHA solve can use most of that on its own. Ten of them will not fit, the stage is killed, and the error blames setup rather than anything you would think to look at.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The limit worth knowing before you build on this<\/h2>\n<p>Tokens are single use and stay valid for roughly two minutes. Both halves bite here. Single use means you need at least as many tokens as protected requests, so a test that hits the guarded endpoint a thousand times needs a thousand solves and no longer resembles a load test. Two minutes means the tokens you solved in setup are already ageing when the first VU starts, so a long soak test will spend most of its run submitting expired ones.<\/p>\n<p>So this pattern fits a short burst against a protected endpoint, not a thirty minute soak. <a href=\"https:\/\/capskip.com\/recaptcha-token-expiration\/\">The post on how long a reCAPTCHA token stays valid<\/a> has the timings. If you need sustained load through the widget, the allowlist route from earlier is the only honest way to get it.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 3: keep the solver out of your metrics<\/h2>\n<p>Anything you send with k6&#8217;s http module lands in http_req_duration, solver calls included. A p95 that quietly includes a fifteen second poll is not a number you can act on. Tag the requests you care about and point the thresholds at the tag.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">export const options = {\n  vus: 10,\n  duration: '90s',\n  setupTimeout: '5m',\n  thresholds: {\n    \/\/ Measure the app, not the solve.\n    'http_req_duration{target:app}': ['p(95)&lt;500'],\n  },\n};\n\nexport default function (data) {\n  const token = data.tokens[__VU - 1];\n\n  http.post(PAGE, { 'g-recaptcha-response': token }, {\n    tags: { target: 'app' },\n  });\n}<\/pre>\n<\/div>\n<p>The __VU variable numbers virtual users from one, so indexing the token array with it gives each VU its own. Tagging is the cleaner fix than moving the solve somewhere k6 cannot see, because the solve requests still show up in the output when you want to know how long they took.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Where the solver has to live<\/h2>\n<p>Load generators are rarely your desktop. k6 runs in CI, on a dedicated box, or on a managed service, and the loopback address on any of those is not the machine your solver is on.<\/p>\n<table>\n<thead>\n<tr>\n<th>Mode<\/th>\n<th>Listens on<\/th>\n<th>Use it when<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Local<\/td>\n<td>127.0.0.1, that device only<\/td>\n<td>k6 and the solver on one machine<\/td>\n<\/tr>\n<tr>\n<td>Server<\/td>\n<td>Your network address or public IP<\/td>\n<td>CI, a load generator fleet, a managed runner<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Server mode is the answer for everything in the second row: change the listen address in the app, point the script at that host, and every generator shares one solver. A static public IP is recommended when the callers sit outside your network. It is still your hardware and still unmetered, so this moves where the solver runs and nothing else. CapSkip is a Windows application, so that is one Windows box the generators call into. One practical note: do not put it behind the same load balancer you are testing, or you will be measuring your own bottleneck twice.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Common errors<\/h2>\n<table>\n<thead>\n<tr>\n<th>What you see<\/th>\n<th>Cause<\/th>\n<th>Fix<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Cannot find module &#8216;capskip&#8217;<\/td>\n<td>k6 does not resolve npm packages<\/td>\n<td>Call the API with k6&#8217;s http module instead<\/td>\n<\/tr>\n<tr>\n<td>setup() execution timed out<\/td>\n<td>Solves outlasted the 60 second default<\/td>\n<td>Raise setupTimeout to cover every solve<\/td>\n<\/tr>\n<tr>\n<td>p95 is huge and nothing is slow<\/td>\n<td>Solver calls counted in http_req_duration<\/td>\n<td>Tag app requests and filter the threshold<\/td>\n<\/tr>\n<tr>\n<td>Later iterations rejected, early ones fine<\/td>\n<td>Tokens aged past their lifetime<\/td>\n<td>Shorten the run or solve fewer, later<\/td>\n<\/tr>\n<tr>\n<td>Every VU gets the same rejection<\/td>\n<td>One token reused across VUs<\/td>\n<td>Solve one per VU and index with __VU<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The full list of codes and the parameters each method takes is in <a href=\"https:\/\/capskip.com\/api-docs\/\">the CapSkip API documentation<\/a>.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">FAQ<\/h2>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can I install the CapSkip SDK into k6?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">No. k6 implements its own module loader that handles built-in modules, local files and remote scripts, and deliberately does not follow Node&#8217;s resolution algorithm, so npm packages are out. The API is 2captcha compatible, so the two endpoints cover everything the SDK would have done for you in about fifteen lines of k6&#8217;s own http module.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Should I solve inside the default function instead?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Only if the solve itself is what you are testing. The default function runs once per iteration per VU, so twenty VUs for two minutes is hundreds of solves, and your latency numbers become a measurement of polling. Solve in setup, hand the tokens out, and keep the iteration to the request you actually care about.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">My k6 runs on a managed service. Can it reach the solver?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes, in Server mode. The solver listens on a network address rather than the loopback address, and the generators call it over the API like any other internal service. A static public IP makes that stable when the runners sit outside your network. Turn on key validation and give each environment its own key so one can be revoked without touching the others.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">How do I load test through a widget for an hour?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">You do not, at least not through real tokens. Single use plus a two minute lifetime means an hour of sustained load needs a continuous stream of solves, and at that point the solver is the system under test. For a long run on your own application, exempt the load generator from the challenge and test the path behind it.<\/p>\n<\/details>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The short version<\/h2>\n<p>Use k6&#8217;s http module against in.php and res.php, solve in setup with setupTimeout raised, tag your application requests so the thresholds stay meaningful, and keep the run short enough that the tokens are still alive. For the crawl side of this see <a href=\"https:\/\/capskip.com\/captcha-solver-for-web-scraping\/\">the CAPTCHA solver for web scraping page<\/a>. Load testing is where per solve pricing gets absurd fastest, because a single burst can need hundreds of tokens that produce no business value at all. The pricing model is the whole difference: <a href=\"https:\/\/capskip.com\/\">captcha bypass<\/a> running on hardware you already own costs the same whether you run one test or forty.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>k6 \u6ca1\u6709 npm\uff0c\u4e5f\u6ca1\u6709 Node \u6a21\u5757\uff0cSDK \u88c5\u4e0d\u8fdb\u53bb\u3002\u4e24\u6b21 k6\/http \u8c03\u7528\u76f4\u63a5\u8bf7\u6c42\u539f\u59cb API\uff0c\u5728 setup \u9636\u6bb5\u5b8c\u6210\u8bc6\u522b\uff0c\u9608\u503c\uff08thresholds\uff09\u8861\u91cf\u7684\u4f9d\u7136\u662f\u4f60\u7684\u5e94\u7528\u3002<\/p>","protected":false},"author":1,"featured_media":25459,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"k6 CAPTCHA: Solve It in the Setup Stage | CapSkip","rank_math_description":"A k6 captcha step cannot use the Node SDK, because k6 is not Node. Solve in setup with two k6\/http calls, tag the requests, keep your metrics honest.","rank_math_focus_keyword":"k6 captcha","footnotes":""},"categories":[70],"tags":[],"class_list":["post-25460","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-captcha"],"_links":{"self":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25460","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/comments?post=25460"}],"version-history":[{"count":1,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25460\/revisions"}],"predecessor-version":[{"id":25462,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25460\/revisions\/25462"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media\/25459"}],"wp:attachment":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media?parent=25460"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/categories?post=25460"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/tags?post=25460"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}