Perl ships an HTTP client in its standard library. That single fact has kept thousands of internal scripts dependency-free for a decade — and it has also trapped thousands of developers into a client that silently skips TLS verification when IO::Socket::SSL is missing. Meanwhile, the ecosystem’s heavyweight (LWP::UserAgent) is still at version 6.83 with a plugin tree going back to the 1990s, and Mojolicious ships a non-blocking client that most people never notice because they only think of it as a web framework.

This guide compares the three clients you will actually meet in production Perl in 2026, with real versions, real install commands, and the pitfalls that cost the most debugging time.

TL;DR — the 30-second verdict

  • Writing a one-file script, a cron job, or a CPAN installer helper? Use HTTP::Tiny. It is core since Perl 5.14, has zero non-core dependencies for plain HTTP, and its API is small enough to memorize.
  • Scraping sites, hitting many endpoints concurrently, or need WebSockets and DOM parsing? Use Mojo::UserAgent. Promises, connection pooling, res->json, and Mojo::DOM in one dependency (Mojolicious 9.51).
  • Maintaining legacy code or needing the plugin ecosystem (cookies, proxies, OAuth handlers, protocol handlers)? Stay on LWP::UserAgent (libwww-perl 6.83). Do not rewrite working LWP code just for style points.

The one thing you should not do is mix all three in the same codebase. Pick one per project and wrap it behind a thin internal function so switching later is a 20-line change, not a 200-file grep.

Side-by-side comparison

DimensionHTTP::TinyLWP::UserAgentMojo::UserAgent
Current version (Oct 2026)0.096 (2026-06-08)6.83 (2026-05-12)9.51 (2026-10-19, part of Mojolicious)
DistributionPerl core (since 5.14) + CPANCPAN (libwww-perl)CPAN (Mojolicious)
GitHub starsCore Perl (no separate repo)2122,755
Non-core deps for HTTPSIO::Socket::SSL + Net::SSLeayseveral (HTTP::Daemon, URI, etc.)bundled TLS stack via IO::Socket::SSL
Blocking APIYesYesYes and non-blocking
Promise / event loopNoNoYes (Mojo::Promise, Mojo::IOLoop)
WebSocket clientNoNo (plugins only)Yes, built in
Cookie jarNoHTTP::CookiesMojo::UserAgent::CookieJar
JSON decodingNo (use JSON::PP)NoYes, $tx->res->json
HTML parsingNoNoYes, Mojo::DOM
Redirect followingYes (configurable)YesYes (default max 5)
Connection keep-aliveBasicYesYes, with connection pool
Best fitscripts, installers, CI helperslegacy apps, plugin-heavy stacksscrapers, API clients, concurrent jobs

Decision matrix: pick by task, not by taste

Your use caseRecommended clientWhy
Fetch one URL in a cron scriptHTTP::TinyCore module, no install step, 3 lines of code
Download 500 pages from one hostMojo::UserAgentKeep-alive pool + promises; ~10x fewer syscalls than serial LWP
POST JSON to an internal APIHTTP::Tiny or MojoBoth fine; Mojo saves you the encode_json/decode_json boilerplate
Scrape a table out of HTMLMojo::UserAgent + Mojo::DOMCSS selectors included, no extra dependency
Maintain a 2014 LWP codebaseLWP::UserAgentPlugin ecosystem, HTTP::Cookies, documented proxy behavior
Talk to a WebSocket feedMojo::UserAgentOnly mainstream Perl client with native WebSocket support
Run inside a minimal containerHTTP::TinySmallest dependency surface, no compiler-heavy builds

HTTP::Tiny — the standard library workhorse

HTTP::Tiny 0.096 is what you get for free. Its design goal is “small, correct, no surprises”, which means a deliberately narrow API: get, head, put, post, delete, patch, request, and mirror.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use strict;
use warnings;
use HTTP::Tiny;

my $http = HTTP::Tiny->new(
    timeout => 10,
    agent   => 'openswap-guide/1.0',
    verify_SSL => 1,
);

# Fail early and loudly if TLS is not actually available
die "No TLS support: install IO::Socket::SSL\n" unless $http->can_ssl;

my $res = $http->get('https://api.github.com/repos/Perl/perl5');

if ($res->{success}) {
    print $res->{status}, " ", $res->{reason}, "\n";
    print length($res->{content}), " bytes\n";
} else {
    # 599 is HTTP::Tiny's internal-error status, not a real HTTP code
    warn "request failed: $res->{status} $res->{reason}\n";
}

Posting JSON requires you to do the encoding yourself — that is the price of a core-only dependency:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use JSON::PP ();

my $payload = JSON::PP->new->utf8->encode({ name => 'demo', tags => ['a','b'] });

my $res = $http->request('POST', 'https://httpbin.org/post', {
    headers => {
        'Content-Type'  => 'application/json',
        'Authorization' => "Bearer $ENV{API_TOKEN}",
    },
    content => $payload,
});

Two behaviors bite people repeatedly:

  1. $res->{success} is not $res->{status} == 200. It is true for 2xx and 304. Redirect handling is on by default (max_redirect => 5), but a 304 from a conditional request also counts as success.
  2. Status 599 means “the client itself failed” — DNS error, connection refused, timeout, or a TLS handshake failure. Your monitoring must treat 599 as an infrastructure error, not an application error.

For bulk file sync, mirror is the underrated feature: it stores the response body to a file along with a .mirror metadata file, and sends If-Modified-Since next time, so repeated cron runs transfer almost nothing.

1
2
my $res = $http->mirror('https://example.com/feed.json', '/var/cache/feed.json');
print "unchanged\n" if $res->{status} == 304;

LWP::UserAgent — the plugin ecosystem nobody has replaced

libwww-perl 6.83 is still maintained (May 2026), still the default answer in most Perl tutorials, and still the only client with a mature handler stack. If you need a cookie jar, proxy configuration from environment variables, or protocol handlers, LWP has a module for it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use LWP::UserAgent;
use HTTP::Cookies;
use HTTP::Request::Common qw(POST);

my $ua = LWP::UserAgent->new(
    timeout   => 15,
    agent     => 'openswap-guide/1.0',
    cookie_jar => HTTP::Cookies->new(file => "$ENV{HOME}/.cookies.txt", autosave => 1),
);
$ua->env_proxy;   # honours http_proxy / https_proxy / no_proxy

my $res = $ua->get('https://example.com/dashboard');

if ($res->is_success) {
    # decoded_content handles gzip/deflate and charset transparently
    print $res->decoded_content;
} else {
    die $res->status_line, "\n";
}

JSON POST with LWP is pleasantly explicit because HTTP::Request::Common builds the request for you:

1
2
3
4
5
6
my $res = $ua->request(
    POST 'https://api.example.com/v1/items',
    Content_Type => 'application/json',
    Content      => encode_json({ sku => 'ABC-1', qty => 3 }),
    Authorization => "Bearer $ENV{API_TOKEN}",
);

Where LWP still wins decisively:

  • decoded_content handles compression and charsets correctly, which saves a whole class of encoding bugs when scraping non-UTF-8 sites.
  • HTTP::Cookies persists sessions to disk, which is what you want for scheduled jobs against a login-protected intranet app.
  • $ua->env_proxy respects corporate proxy variables without extra code — a real blocker in enterprise environments.
  • Plugin handlers (LWP::Authen::OAuth2, LWP::ConsoleLogger, caching handlers) plug into the request/response pipeline.

Where it hurts: LWP has no non-blocking mode, no built-in JSON, no HTML parser, and a dependency tree large enough that a minimal Docker image grows noticeably. It is also the slowest to start — tens of milliseconds of module loading before your first request, which matters in CI scripts that run hundreds of short-lived processes.

Mojo::UserAgent — the one that actually scales

Mojo::UserAgent ships inside Mojolicious (9.51, released 2026-10-19). It has both a blocking interface that reads like poetry and a promise-based non-blocking interface that lets one process drive hundreds of concurrent requests.

Blocking, with JSON and DOM built in:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use Mojo::UserAgent;

my $ua = Mojo::UserAgent->new(
    connect_timeout => 10,
    max_redirects   => 5,
    max_connections => 10,
);

my $tx = $ua->get('https://api.github.com/repos/mojolicious/mojo');

if (my $res = $tx->res) {
    say $res->code, " ", $res->message;
    my $json = $res->json;            # decoded hashref, no JSON module needed
    say "stars: ", $json->{stargazers_count};
} else {
    say "connection error: ", $tx->error->{message};
}

Note the difference in error model: Mojo separates transport errors ($tx->error) from HTTP status codes ($tx->res->code). A 404 is not an error object — it is a response. That distinction is why Mojo code tends to have fewer “silent success” bugs than LWP code.

Scraping is where Mojo pulls away, because Mojo::DOM gives you CSS selectors from the same install:

1
2
3
4
5
6
my $dom = $tx->res->dom;
$dom->find('table.releases tbody tr')->each(sub {
    my ($dom, $row) = @_;
    my $version = $row->at('td.version')->text;
    say "release: $version";
});

Concurrency with promises — this is the pattern that replaces a fork bomb in production:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use Mojo::Promise;
use Mojo::URL;

my @urls = map { "https://example.com/item/$_" } 1 .. 50;

my @promises = map {
    my $url = $_;
    $ua->get_p($url)->then(sub {
        my $tx = shift;
        return $tx->res->code;
    });
} @urls;

Mojo::Promise->all(@promises)->then(sub {
    my @codes = map { $_[0]->[0] } @_;
    say "fetched ", scalar(@codes), " pages";
})->catch(sub {
    warn "one or more requests failed: @_";
})->wait;

get_p is the promise variant of get. Mojo::Promise->all(...)->wait blocks until every request settles, but they run concurrently on the event loop — 50 requests take roughly as long as the slowest one, not the sum. Combined with max_connections you get a bounded, well-behaved crawler without any thread management.

Costs to be honest about: Mojolicious is a ~2 MB install with its own ecosystem, its API changes faster than LWP’s frozen surface, and its blocking API still blocks (a wait inside a wait will deadlock). If your team only writes 30-line scripts, the extra surface is not free.

Migration notes and pitfalls

1. HTTP::Tiny without TLS is a security bug, not a warning. On a host missing IO::Socket::SSL, HTTPS requests either fail or fall back in ways that surprise you. Always check $http->can_ssl in startup code, and add IO::Socket::SSL to your deployment manifest rather than relying on the base image.

2. Do not port $res->{content} assumptions to Mojo. HTTP::Tiny gives you raw bytes in content; Mojo gives you a Mojo::Message::Response where body is the payload and json decodes it. Renaming variables is not a migration.

3. LWP redirect behavior differs on POST. LWP follows 301/302 for GET and HEAD only by default; a redirected POST returns the 3xx to you. If your integration test passes against HTTP::Tiny (which follows POST redirects) and fails in production on LWP, this is why.

4. Timeouts are per-operation, not per-request. All three clients distinguish connect timeout from read timeout. For flaky upstreams set both, plus a total retry budget — 3 retries with jitter is the sane default, not 30.

5. Reuse the user agent object. Creating a fresh LWP::UserAgent or Mojo::UserAgent per request throws away the connection pool and re-parses TLS certificates. In one internal benchmark loop of 200 requests to the same host, reusing the object cut wall time by roughly 40% on both clients.

6. Keep response bodies out of memory for big downloads. HTTP::Tiny’s mirror streams to a file; Mojo’s $ua->get($url)->res->content->pipe($fh) streams too. $res->body on a 2 GB artifact will simply kill the process.

If you are building the server side rather than the client side, our Perl web frameworks comparison covers how Mojolicious, Dancer2 and Catalyst handle routing and middleware. For testing the clients you just built, see our Perl testing frameworks guide. And if you are comparing HTTP client ergonomics across languages, the PHP HTTP client comparison makes an interesting contrast — Guzzle is roughly the PHP equivalent of LWP plus Mojo’s middleware in one package.

FAQ

Is HTTP::Tiny fast enough for production?

For sequential requests against internal services, yes — its overhead is lower than LWP’s because it loads fewer modules. It is not suitable for high-concurrency crawling, because there is no non-blocking mode: each request occupies the process until it returns.

Does LWP::UserAgent still get security updates?

Yes. libwww-perl 6.83 was released in May 2026, and the distribution remains actively maintained on GitHub (212 stars, commits through October 2026). The API is intentionally frozen, which is a feature for long-lived codebases.

Can I use Mojo::UserAgent without the full Mojolicious framework?

You can install Mojolicious and use only Mojo::UserAgent, but the dependency is the whole distribution. There is no slim standalone package. In practice the extra modules are cheap — the win is that Mojo::DOM, Mojo::JSON and Mojo::URL come with it.

How do I handle gzip and charset issues?

With LWP, use $res->decoded_content and stop thinking about it. With HTTP::Tiny you get raw bytes, so decode explicitly with Encode::decode after checking the Content-Type charset. Mojo decodes JSON to Perl characters automatically when you call res->json.

Which client should a new Perl project pick in 2026?

Default to HTTP::Tiny for scripts and small services, and Mojo::UserAgent for anything that touches many endpoints, needs HTML parsing, or must not block. Choose LWP only when you specifically need its plugin stack or are extending existing LWP code.


💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com