<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: http-range-requests</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/http-range-requests.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-02-27T17:50:54+00:00</updated><author><name>Simon Willison</name></author><entry><title>Unicode Explorer using binary search over fetch() HTTP range requests</title><link href="https://simonwillison.net/2026/Feb/27/unicode-explorer/#atom-tag" rel="alternate"/><published>2026-02-27T17:50:54+00:00</published><updated>2026-02-27T17:50:54+00:00</updated><id>https://simonwillison.net/2026/Feb/27/unicode-explorer/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/unicode-binary-search"&gt;Unicode Explorer using binary search over fetch() HTTP range requests&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Here's a little prototype I built this morning from my phone as an experiment in HTTP range requests, and a general example of using LLMs to satisfy curiosity.&lt;/p&gt;
&lt;p&gt;I've been collecting &lt;a href="https://simonwillison.net/tags/http-range-requests/"&gt;HTTP range tricks&lt;/a&gt; for a while now, and I decided it would be fun to build something with them myself that used binary search against a large file to do something useful.&lt;/p&gt;
&lt;p&gt;So I &lt;a href="https://claude.ai/share/47860666-cb20-44b5-8cdb-d0ebe363384f"&gt;brainstormed with Claude&lt;/a&gt;. The challenge was coming up with a use case for binary search where the data could be naturally sorted in a way that would benefit from binary search.&lt;/p&gt;
&lt;p&gt;One of Claude's suggestions was looking up information about unicode codepoints, which means searching through many MBs of metadata.&lt;/p&gt;
&lt;p&gt;I had Claude write me a spec to feed to Claude Code - &lt;a href="https://github.com/simonw/research/pull/90#issue-4001466642"&gt;visible here&lt;/a&gt; - then kicked off an &lt;a href="https://simonwillison.net/2025/Nov/6/async-code-research/"&gt;asynchronous research project&lt;/a&gt; with Claude Code for web against my &lt;a href="https://github.com/simonw/research"&gt;simonw/research&lt;/a&gt; repo to turn that into working code.&lt;/p&gt;
&lt;p&gt;Here's the &lt;a href="https://github.com/simonw/research/tree/main/unicode-explorer-binary-search#readme"&gt;resulting report and code&lt;/a&gt;. One interesting thing I learned is that Range request tricks aren't compatible with HTTP compression because they mess with the byte offset calculations. I added &lt;code&gt;'Accept-Encoding': 'identity'&lt;/code&gt; to the &lt;code&gt;fetch()&lt;/code&gt; calls but this isn't actually necessary because Cloudflare and other CDNs automatically skip compression if a &lt;code&gt;content-range&lt;/code&gt; header is present.&lt;/p&gt;
&lt;p&gt;I deployed the result &lt;a href="https://tools.simonwillison.net/unicode-binary-search"&gt;to my tools.simonwillison.net site&lt;/a&gt;, after first tweaking it to query the data via range requests against a CORS-enabled 76.6MB file in an S3 bucket fronted by Cloudflare.&lt;/p&gt;
&lt;p&gt;The demo is fun to play with - type in a single character like &lt;code&gt;ø&lt;/code&gt; or a hexadecimal codepoint indicator like &lt;code&gt;1F99C&lt;/code&gt; and it will binary search its way through the large file and show you the steps it takes along the way:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Animated demo of a web tool called Unicode Explore. I enter the ampersand character and hit Search. A box below shows a sequence of HTTP binary search requests made, finding in 17 steps with 3,864 bytes transferred and telling me that ampersand is U+0026 in Punctuation other, Basic Latin" src="https://static.simonwillison.net/static/2026/unicode-explore.gif" /&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/algorithms"&gt;algorithms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http"&gt;http&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/research"&gt;research&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/unicode"&gt;unicode&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;



</summary><category term="algorithms"/><category term="http"/><category term="research"/><category term="tools"/><category term="unicode"/><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="vibe-coding"/><category term="http-range-requests"/></entry><entry><title>Unicode Explorer — Binary Search Over HTTP</title><link href="https://simonwillison.net/2026/Feb/27/unicode-explorer-binary-search/#atom-tag" rel="alternate"/><published>2026-02-27T16:02:00+00:00</published><updated>2026-02-27T16:02:00+00:00</updated><id>https://simonwillison.net/2026/Feb/27/unicode-explorer-binary-search/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/unicode-explorer-binary-search#readme"&gt;Unicode Explorer — Binary Search Over HTTP&lt;/a&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/unicode"&gt;unicode&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="http-range-requests"/><category term="unicode"/></entry><entry><title>Unicode Explorer — Binary Search Over HTTP</title><link href="https://simonwillison.net/2026/Feb/27/unicode-binary-search/#atom-tag" rel="alternate"/><published>2026-02-27T16:01:33+00:00</published><updated>2026-02-27T16:01:33+00:00</updated><id>https://simonwillison.net/2026/Feb/27/unicode-binary-search/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/unicode-binary-search"&gt;Unicode Explorer — Binary Search Over HTTP&lt;/a&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/unicode"&gt;unicode&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="http-range-requests"/><category term="unicode"/></entry><entry><title>HTTP Range Requests for Wheel Metadata</title><link href="https://simonwillison.net/2026/Feb/18/http-range-wheel-metadata/#atom-tag" rel="alternate"/><published>2026-02-18T22:51:00+00:00</published><updated>2026-02-18T22:51:00+00:00</updated><id>https://simonwillison.net/2026/Feb/18/http-range-wheel-metadata/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/http-range-wheel-metadata#readme"&gt;HTTP Range Requests for Wheel Metadata&lt;/a&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="http-range-requests"/></entry><entry><title>Gwtar: a static efficient single-file HTML format</title><link href="https://simonwillison.net/2026/Feb/15/gwtar/#atom-tag" rel="alternate"/><published>2026-02-15T18:26:08+00:00</published><updated>2026-02-15T18:26:08+00:00</updated><id>https://simonwillison.net/2026/Feb/15/gwtar/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://gwern.net/gwtar"&gt;Gwtar: a static efficient single-file HTML format&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Fascinating new project from Gwern Branwen and Said Achmiz that targets the challenge of combining large numbers of assets into a single archived HTML file without that file being inconvenient to view in a browser.&lt;/p&gt;
&lt;p&gt;The key trick it uses is to fire &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/stop"&gt;window.stop()&lt;/a&gt; early in the page to prevent the browser from downloading the whole thing, then following that call with inline tar uncompressed content.&lt;/p&gt;
&lt;p&gt;It can then make HTTP range requests to fetch content from that tar data on-demand when it is needed by the page.&lt;/p&gt;
&lt;p&gt;The JavaScript that has already loaded rewrites asset URLs to point to &lt;code&gt;https://localhost/&lt;/code&gt; purely so that they will fail to load. Then it uses a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver"&gt;PerformanceObserver&lt;/a&gt; to catch those attempted loads:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let perfObserver = new PerformanceObserver((entryList, observer) =&amp;gt; {
    resourceURLStringsHandler(entryList.getEntries().map(entry =&amp;gt; entry.name));
});
perfObserver.observe({ entryTypes: [ "resource" ] });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;resourceURLStringsHandler&lt;/code&gt; callback finds the resource if it is already loaded or fetches it with an HTTP range request otherwise and then inserts the resource in the right place using a &lt;code&gt;blob:&lt;/code&gt; URL.&lt;/p&gt;
&lt;p&gt;Here's what the &lt;code&gt;window.stop()&lt;/code&gt; portion of the document looks like if you view the source:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a macOS terminal window titled &amp;quot;gw — more big.html — 123×46&amp;quot; showing the source code of a gwtar (self-extracting HTML archive) file. The visible code includes JavaScript with requestIdleCallback(getMainPageHTML);, a noscript block with warnings: a &amp;quot;js-disabled-warning&amp;quot; stating &amp;quot;This HTML page requires JavaScript to be enabled to render, as it is a self-extracting gwtar HTML file,&amp;quot; a description of gwtar as &amp;quot;a portable self-contained standalone HTML file which is designed to nevertheless support efficient lazy loading of all assets such as large media files,&amp;quot; with a link to https://gwern.net/gwtar, a &amp;quot;local-file-warning&amp;quot; with a shell command perl -ne'print $_ if $x; $x=1 if /&amp;lt;!-- GWTAR END/' &amp;amp;lt; foo.gwtar.html | tar --extract, and a &amp;quot;server-fail-warning&amp;quot; about misconfigured servers. Below the HTML closing tags and &amp;lt;!-- GWTAR END comment is binary tar archive data with the filename 2010-02-brianmoriarty-thesecretofpsalm46.html, showing null-padded tar header fields including ustar^@00root and octal size/permission values. At the bottom, a SingleFile metadata comment shows url: https://web.archive.org/web/20230512001411/http://ludix.com/moriarty/psalm46.html and saved date: Sat Jan 17 2026 19:26:49 GMT-0800 (Pacific Standard Time)." src="https://static.simonwillison.net/static/2026/gwtar.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;Amusingly for an archive format it doesn't actually work if you open the file directly on your own computer. Here's what you see if you try to do that:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You are seeing this message, instead of the page you should be seeing, because &lt;code&gt;gwtar&lt;/code&gt; files &lt;strong&gt;cannot be opened locally&lt;/strong&gt; (due to web browser security restrictions).&lt;/p&gt;
&lt;p&gt;To open this page on your computer, use the following shell command:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;perl -ne'print $_ if $x; $x=1 if /&amp;lt;!-- GWTAR END/' &amp;lt; foo.gwtar.html | tar --extract&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Then open the file &lt;code&gt;foo.html&lt;/code&gt; in any web browser.&lt;/p&gt;
&lt;/blockquote&gt;

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=47024506"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/archiving"&gt;archiving&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/html"&gt;html&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;



</summary><category term="archiving"/><category term="html"/><category term="javascript"/><category term="http-range-requests"/></entry><entry><title>How uv got so fast</title><link href="https://simonwillison.net/2025/Dec/26/how-uv-got-so-fast/#atom-tag" rel="alternate"/><published>2025-12-26T23:43:15+00:00</published><updated>2025-12-26T23:43:15+00:00</updated><id>https://simonwillison.net/2025/Dec/26/how-uv-got-so-fast/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://nesbitt.io/2025/12/26/how-uv-got-so-fast.html"&gt;How uv got so fast&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Andrew Nesbitt provides an insightful teardown of why &lt;a href="https://github.com/astral-sh/uv"&gt;uv&lt;/a&gt; is so much faster than &lt;code&gt;pip&lt;/code&gt;. It's not nearly as simple as just "they rewrote it in Rust" - &lt;code&gt;uv&lt;/code&gt; gets to skip a huge amount of Python packaging history (which &lt;code&gt;pip&lt;/code&gt; needs to implement for backwards compatibility) and benefits enormously from work over recent years that makes it possible to resolve dependencies across most packages without having to execute the code in &lt;code&gt;setup.py&lt;/code&gt; using a Python interpreter.&lt;/p&gt;
&lt;p&gt;Two notes that caught my eye that I hadn't understood before:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;HTTP range requests for metadata.&lt;/strong&gt; &lt;a href="https://packaging.python.org/en/latest/specifications/binary-distribution-format/"&gt;Wheel files&lt;/a&gt; are zip archives, and zip archives put their file listing at the end. uv tries PEP 658 metadata first, falls back to HTTP range requests for the zip central directory, then full wheel download, then building from source. Each step is slower and riskier. The design makes the fast path cover 99% of cases. None of this requires Rust.&lt;/p&gt;
&lt;p&gt;[...]&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Compact version representation&lt;/strong&gt;. uv packs versions into u64 integers where possible, making comparison and hashing fast. Over 90% of versions fit in one u64. This is micro-optimization that compounds across millions of comparisons.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I wanted to learn more about these tricks, so I fired up &lt;a href="https://simonwillison.net/2025/Nov/6/async-code-research/"&gt;an asynchronous research task&lt;/a&gt; and told it to checkout the &lt;code&gt;astral-sh/uv&lt;/code&gt; repo, find the Rust code for both of those features and try porting it to Python to help me understand how it works.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/simonw/research/tree/main/http-range-wheel-metadata"&gt;the report that it wrote for me&lt;/a&gt;, the &lt;a href="https://github.com/simonw/research/pull/57"&gt;prompts I used&lt;/a&gt; and the &lt;a href="https://gistpreview.github.io/?0f04e4d1a240bfc3065df5082b629884/index.html"&gt;Claude Code transcript&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can try &lt;a href="https://github.com/simonw/research/blob/main/http-range-wheel-metadata/wheel_metadata.py"&gt;the script&lt;/a&gt; it wrote for extracting metadata from a wheel using HTTP range requests like this:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;uv run --with httpx https://raw.githubusercontent.com/simonw/research/refs/heads/main/http-range-wheel-metadata/wheel_metadata.py https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl -v&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The Playwright wheel there is ~40MB. Adding &lt;code&gt;-v&lt;/code&gt; at the end causes the script to spit out verbose details of how it fetched the data - &lt;a href="https://gist.github.com/simonw/a5ef83b6e4605d2577febb43fa9ad018"&gt;which looks like this&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Key extract from that output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[1] HEAD request to get file size...
    File size: 40,775,575 bytes
[2] Fetching last 16,384 bytes (EOCD + central directory)...
    Received 16,384 bytes
[3] Parsed EOCD:
    Central directory offset: 40,731,572
    Central directory size: 43,981
    Total entries: 453
[4] Fetching complete central directory...
    ...
[6] Found METADATA: playwright-1.57.0.dist-info/METADATA
    Offset: 40,706,744
    Compressed size: 1,286
    Compression method: 8
[7] Fetching METADATA content (2,376 bytes)...
[8] Decompressed METADATA: 3,453 bytes

Total bytes fetched: 18,760 / 40,775,575 (100.0% savings)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The section of the report &lt;a href="https://github.com/simonw/research/tree/main/http-range-wheel-metadata#bonus-compact-version-representation"&gt;on compact version representation&lt;/a&gt; is interesting too. Here's how it illustrates sorting version numbers correctly based on their custom u64 representation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sorted order (by integer comparison of packed u64):
  1.0.0a1 (repr=0x0001000000200001)
  1.0.0b1 (repr=0x0001000000300001)
  1.0.0rc1 (repr=0x0001000000400001)
  1.0.0 (repr=0x0001000000500000)
  1.0.0.post1 (repr=0x0001000000700001)
  1.0.1 (repr=0x0001000100500000)
  2.0.0.dev1 (repr=0x0002000000100001)
  2.0.0 (repr=0x0002000000500000)
&lt;/code&gt;&lt;/pre&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/performance"&gt;performance&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sorting"&gt;sorting&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rust"&gt;rust&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-porting"&gt;vibe-porting&lt;/a&gt;&lt;/p&gt;



</summary><category term="performance"/><category term="python"/><category term="sorting"/><category term="rust"/><category term="uv"/><category term="http-range-requests"/><category term="vibe-porting"/></entry><entry><title>OpenTimes</title><link href="https://simonwillison.net/2025/Mar/17/opentimes/#atom-tag" rel="alternate"/><published>2025-03-17T22:49:59+00:00</published><updated>2025-03-17T22:49:59+00:00</updated><id>https://simonwillison.net/2025/Mar/17/opentimes/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://sno.ws/opentimes/"&gt;OpenTimes&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Spectacular new open geospatial project by &lt;a href="https://sno.ws/"&gt;Dan Snow&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;OpenTimes is a database of pre-computed, point-to-point travel times between United States Census geographies. It lets you download bulk travel time data for free and with no limits.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://opentimes.org/?id=060816135022&amp;amp;mode=car#9.76/37.5566/-122.3085"&gt;what I get&lt;/a&gt; for travel times by car from El Granada, California:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Isochrone map showing driving times from the El Granada census tract to other places in the San Francisco Bay Area" src="https://static.simonwillison.net/static/2025/opentimes.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;The technical details are &lt;em&gt;fascinating&lt;/em&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The entire OpenTimes backend is just static Parquet files on &lt;a href="https://www.cloudflare.com/developer-platform/products/r2/"&gt;Cloudflare's R2&lt;/a&gt;. There's no RDBMS or running service, just files and a CDN. The whole thing costs about $10/month to host and costs nothing to serve. In my opinion, this is a &lt;em&gt;great&lt;/em&gt; way to serve infrequently updated, large public datasets at low cost (as long as you partition the files correctly).&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Sure enough, &lt;a href="https://developers.cloudflare.com/r2/pricing/"&gt;R2 pricing&lt;/a&gt; charges "based on the total volume of data stored" - $0.015 / GB-month for standard storage, then $0.36 / million requests for "Class B" operations which include reads. They charge nothing for outbound bandwidth.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;All travel times were calculated by pre-building the inputs (OSM, OSRM networks) and then distributing the compute over &lt;a href="https://github.com/dfsnow/opentimes/actions/workflows/calculate-times.yaml"&gt;hundreds of GitHub Actions jobs&lt;/a&gt;. This worked shockingly well for this specific workload (and was also completely free).&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's a &lt;a href="https://github.com/dfsnow/opentimes/actions/runs/13094249792"&gt;GitHub Actions run&lt;/a&gt; of the &lt;a href="https://github.com/dfsnow/opentimes/blob/a6a5f7abcdd69559b3e29f360fe0ff0399dbb400/.github/workflows/calculate-times.yaml#L78-L80"&gt;calculate-times.yaml workflow&lt;/a&gt; which uses a matrix to run 255 jobs!&lt;/p&gt;
&lt;p&gt;&lt;img alt="GitHub Actions run: calculate-times.yaml run by workflow_dispatch taking 1h49m to execute 255 jobs with names like run-job (2020-01) " src="https://static.simonwillison.net/static/2025/opentimes-github-actions.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;Relevant YAML:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  matrix:
    year: ${{ fromJSON(needs.setup-jobs.outputs.years) }}
    state: ${{ fromJSON(needs.setup-jobs.outputs.states) }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where those JSON files were created by the previous step, which reads in the year and state values from &lt;a href="https://github.com/dfsnow/opentimes/blob/a6a5f7abcdd69559b3e29f360fe0ff0399dbb400/data/params.yaml#L72-L132"&gt;this params.yaml file&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The query layer uses a single DuckDB database file with &lt;em&gt;views&lt;/em&gt; that point to static Parquet files via HTTP. This lets you query a table with hundreds of billions of records after downloading just the ~5MB pointer file.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is a really creative use of DuckDB's feature that lets you run queries against large data from a laptop using HTTP range queries to avoid downloading the whole thing.&lt;/p&gt;
&lt;p&gt;The README shows &lt;a href="https://github.com/dfsnow/opentimes/blob/3439fa2c54af227e40997b4a5f55678739e0f6df/README.md#using-duckdb"&gt;how to use that from R and Python&lt;/a&gt; - I got this working in the &lt;code&gt;duckdb&lt;/code&gt; client (&lt;code&gt;brew install duckdb&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INSTALL httpfs;
LOAD httpfs;
ATTACH 'https://data.opentimes.org/databases/0.0.1.duckdb' AS opentimes;

SELECT origin_id, destination_id, duration_sec
  FROM opentimes.public.times
  WHERE version = '0.0.1'
      AND mode = 'car'
      AND year = '2024'
      AND geography = 'tract'
      AND state = '17'
      AND origin_id LIKE '17031%' limit 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In answer to a question about adding public transit times &lt;a href="https://news.ycombinator.com/item?id=43392521#43393183"&gt;Dan said&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;In the next year or so maybe. The biggest obstacles to adding public transit are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Collecting all the necessary scheduling data (e.g. GTFS feeds) for every transit system in the county. Not insurmountable since there are services that do this currently.&lt;/li&gt;
&lt;li&gt;Finding a routing engine that can compute nation-scale travel time matrices quickly. Currently, the two fastest open-source engines I've tried (OSRM and Valhalla) don't support public transit for matrix calculations and the engines that do support public transit (R5, OpenTripPlanner, etc.) are too slow.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://gtfs.org/"&gt;GTFS&lt;/a&gt; is a popular CSV-based format for sharing transit schedules - here's &lt;a href="https://gtfs.org/resources/data/"&gt;an official list&lt;/a&gt; of available feed directories.&lt;/p&gt;
&lt;p&gt;This whole project feels to me like a great example of the &lt;a href="https://simonwillison.net/2021/Jul/28/baked-data/"&gt;baked data&lt;/a&gt; architectural pattern in action.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=43392521"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/census"&gt;census&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/geospatial"&gt;geospatial&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-data"&gt;open-data&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openstreetmap"&gt;openstreetmap&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cloudflare"&gt;cloudflare&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/parquet"&gt;parquet&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/baked-data"&gt;baked-data&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/duckdb"&gt;duckdb&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;



</summary><category term="census"/><category term="geospatial"/><category term="open-data"/><category term="openstreetmap"/><category term="cloudflare"/><category term="parquet"/><category term="github-actions"/><category term="baked-data"/><category term="duckdb"/><category term="http-range-requests"/></entry><entry><title>Amazon S3 Express One Zone now supports the ability to append data to an object</title><link href="https://simonwillison.net/2024/Nov/22/amazon-s3-append-data/#atom-tag" rel="alternate"/><published>2024-11-22T04:39:35+00:00</published><updated>2024-11-22T04:39:35+00:00</updated><id>https://simonwillison.net/2024/Nov/22/amazon-s3-append-data/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://aws.amazon.com/about-aws/whats-new/2024/11/amazon-s3-express-one-zone-append-data-object/"&gt;Amazon S3 Express One Zone now supports the ability to append data to an object&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This is a first for Amazon S3: it is now possible to append data to an existing object in a bucket, where previously the only supported operation was to atomically replace the object with an updated version.&lt;/p&gt;
&lt;p&gt;This is only available for S3 Express One Zone, a bucket class introduced &lt;a href="https://aws.amazon.com/blogs/aws/new-amazon-s3-express-one-zone-high-performance-storage-class/"&gt;a year ago&lt;/a&gt; which provides storage in just a single availability zone, providing significantly lower latency at the cost of reduced redundancy and a much higher price (16c/GB/month compared to 2.3c for S3 standard tier).&lt;/p&gt;
&lt;p&gt;The fact that appends have never been supported for multi-availability zone S3 provides an interesting clue as to the underlying architecture. Guaranteeing that every copy of an object has received and applied an append is significantly harder than doing a distributed atomic swap to a new version.&lt;/p&gt;
&lt;p&gt;More details from &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/directory-buckets-objects-append.html"&gt;the documentation&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;There is no minimum size requirement for the data you can append to an object. However, the maximum size of the data that you can append to an object in a single request is 5GB. This is the same limit as the largest request size when uploading data using any Amazon S3 API.&lt;/p&gt;
&lt;p&gt;With each successful append operation, you create a part of the object and each object can have up to 10,000 parts. This means you can append data to an object up to 10,000 times. If an object is created using S3 multipart upload, each uploaded part is counted towards the total maximum of 10,000 parts. For example, you can append up to 9,000 times to an object created by multipart upload comprising of 1,000 parts.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That 10,000 limit means this won't quite work for constantly appending to a log file in a bucket.&lt;/p&gt;
&lt;p&gt;Presumably it will be possible to "tail" an object that is receiving appended updates using the HTTP Range header.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/architecture"&gt;architecture&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/aws"&gt;aws&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/s3"&gt;s3&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/scaling"&gt;scaling&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;



</summary><category term="architecture"/><category term="aws"/><category term="s3"/><category term="scaling"/><category term="http-range-requests"/></entry><entry><title>DuckDB-Wasm: Efficient Analytical SQL in the Browser</title><link href="https://simonwillison.net/2021/Oct/29/duckdb-wasm-efficient-analytical-sql-in-the-browser/#atom-tag" rel="alternate"/><published>2021-10-29T15:25:43+00:00</published><updated>2021-10-29T15:25:43+00:00</updated><id>https://simonwillison.net/2021/Oct/29/duckdb-wasm-efficient-analytical-sql-in-the-browser/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://duckdb.org/2021/10/29/duckdb-wasm.html"&gt;DuckDB-Wasm: Efficient Analytical SQL in the Browser&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
First SQLite, now DuckDB: options for running database engines in the browser using WebAssembly keep on growing. DuckDB means browsers now have a fast, intuitive mechanism for querying Parquet files too. This also supports the same HTTP Range header trick as the SQLite demo from a while back, meaning it can query large databases loaded over HTTP without downloading the whole file.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/hfmuehleisen/status/1454100577730838529"&gt;@hfmuehleisen&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/parquet"&gt;parquet&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/duckdb"&gt;duckdb&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;



</summary><category term="sqlite"/><category term="parquet"/><category term="webassembly"/><category term="duckdb"/><category term="http-range-requests"/></entry><entry><title>Notes on streaming large API responses</title><link href="https://simonwillison.net/2021/Jun/25/streaming-large-api-responses/#atom-tag" rel="alternate"/><published>2021-06-25T16:26:49+00:00</published><updated>2021-06-25T16:26:49+00:00</updated><id>https://simonwillison.net/2021/Jun/25/streaming-large-api-responses/#atom-tag</id><summary type="html">
    &lt;p&gt;I started &lt;a href="https://twitter.com/simonw/status/1405554676993433605"&gt;a Twitter conversation&lt;/a&gt; last week about API endpoints that stream large amounts of data as an alternative to APIs that return 100 results at a time and require clients to paginate through all of the pages in order to retrieve all of the data:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p dir="ltr" lang="en"&gt;Any unexpected downsides to offering streaming HTTP API endpoints that serve up eg 100,000 JSON objects in a go rather than asking users to paginate 100 at a time over 1,000 requests, assuming efficient implementation of that streaming endpoint?&lt;/p&gt;— Simon Willison (@simonw) &lt;a href="https://twitter.com/simonw/status/1405554676993433605?ref_src=twsrc%5Etfw"&gt;June 17, 2021&lt;/a&gt;
&lt;/blockquote&gt;
&lt;p&gt;I got a ton of great replies. I tried to tie them together in a thread attached to the tweet, but I'm also going to synthesize them into some thoughts here.&lt;/p&gt;
&lt;h4&gt;Bulk exporting data&lt;/h4&gt;
&lt;p&gt;The more time I spend with APIs, especially with regard to my &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; and &lt;a href="https://simonwillison.net/2020/Nov/14/personal-data-warehouses/"&gt;Dogsheep&lt;/a&gt; projects, the more I realize that my favourite APIs are the ones that let you extract &lt;em&gt;all&lt;/em&gt; of your data as quickly and easily as possible.&lt;/p&gt;
&lt;p&gt;There are generally three ways an API might provide this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Click an "export everything" button, then wait for a while for an email to show up with a link to a downloadable zip file. This isn't really an API, in particular since it's usually hard if not impossible to automate that initial "click", but it's still better than nothing. Google's &lt;a href="https://takeout.google.com/"&gt;Takeout&lt;/a&gt; is one notable implementation of this pattern.&lt;/li&gt;
&lt;li&gt;Provide a JSON API which allows users to paginate through their data. This is a very common pattern, although it can run into difficulties: what happens if new data is added while you are paginating through the original data, for example? Some systems only allow access to the first N pages too, for performance reasons.&lt;/li&gt;
&lt;li&gt;Providing a single HTTP endpoint you can hit that will return ALL of your data - potentially dozens or hundreds of MBs of it - in one go.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It's that last option that I'm interested in talking about today.&lt;/p&gt;
&lt;h4&gt;Efficiently streaming data&lt;/h4&gt;
&lt;p&gt;It used to be that most web engineers would quickly discount the idea of an API endpoint that streams out an unlimited number of rows. HTTP requests should be served as quickly as possible! Anything more than a couple of seconds spent processing a request is a red flag that something should be reconsidered.&lt;/p&gt;
&lt;p&gt;Almost everything in the web stack is optimized for quickly serving small requests. But over the past decade the tide has turned somewhat: Node.js made async web servers commonplace, WebSockets taught us to handle long-running connections and in the Python world asyncio and &lt;a href="https://asgi.readthedocs.io/"&gt;ASGI&lt;/a&gt; provided a firm foundation for handling long-running requests using smaller amounts of RAM and CPU.&lt;/p&gt;
&lt;p&gt;I've been experimenting in this area for a few years now.&lt;/p&gt;
&lt;p&gt;Datasette has the ability to &lt;a href="https://github.com/simonw/datasette/blob/0.57.1/datasette/views/base.py#L264-L428"&gt;use ASGI trickery&lt;/a&gt; to &lt;a href="https://docs.datasette.io/en/stable/csv_export.html#streaming-all-records"&gt;stream all rows from a table&lt;/a&gt; (or filtered table) as CSV, potentially returning hundreds of MBs of data.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://django-sql-dashboard.datasette.io/"&gt;Django SQL Dashboard&lt;/a&gt; can export the full results of a SQL query as CSV or TSV, this time using Django's &lt;a href="https://docs.djangoproject.com/en/3.2/ref/request-response/#django.http.StreamingHttpResponse"&gt;StreamingHttpResponse&lt;/a&gt; (which does tie up a full worker process, but that's OK if you restrict it to a controlled number of authenticated users).&lt;/p&gt;
&lt;p&gt;&lt;a href="https://simonwillison.net/tags/vaccinateca/"&gt;VIAL&lt;/a&gt; implements streaming responses to offer an &lt;a href="https://github.com/CAVaccineInventory/vial/blob/cdaaab053a9cf1cef40104a2cdf480b7932d58f7/vaccinate/core/admin_actions.py"&gt;"export from the admin" feature&lt;/a&gt;. It also has an API-key-protected search API which can stream out all matching rows &lt;a href="https://github.com/CAVaccineInventory/vial/blob/cdaaab053a9cf1cef40104a2cdf480b7932d58f7/vaccinate/api/serialize.py#L38"&gt;in JSON or GeoJSON&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;Implementation notes&lt;/h4&gt;
&lt;p&gt;The key thing to watch out for when implementing this pattern is memory usage: if your server buffers 100MB+ of data any time it needs to serve an export request you're going to run into trouble.&lt;/p&gt;
&lt;p&gt;Some export formats are friendlier for streaming than others. CSV and TSV are pretty easy to stream, as is newline-delimited JSON.&lt;/p&gt;
&lt;p&gt;Regular JSON requires a bit more thought: you can output a &lt;code&gt;[&lt;/code&gt; character, then output each row in a stream with a comma suffix, then skip the comma for the last row and output a &lt;code&gt;]&lt;/code&gt;. Doing that requires peeking ahead (looping two at a time) to verify that you haven't yet reached the end.&lt;/p&gt;
&lt;p&gt;Or... Martin De Wulf &lt;a href="https://twitter.com/madewulf/status/1405559088994467844"&gt;pointed out&lt;/a&gt; that you can output the first row, then output every other row with a preceeding comma - which avoids the whole "iterate two at a time" problem entirely.&lt;/p&gt;
&lt;p&gt;The next challenge is efficiently looping through every database result without first pulling them all into memory.&lt;/p&gt;
&lt;p&gt;PostgreSQL (and the &lt;code&gt;psycopg2&lt;/code&gt; Python module) offers &lt;a href="https://www.psycopg.org/docs/usage.html#server-side-cursors"&gt;server-side cursors&lt;/a&gt;, which means you can stream results through your code without loading them all at once. I use these &lt;a href="https://github.com/simonw/django-sql-dashboard/blob/dd1bb18e45b40ce8f3d0553a72b7ec3cdc329e69/django_sql_dashboard/views.py#L397-L399"&gt;in Django SQL Dashboard&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Server-side cursors make me nervous though, because they seem like they likely tie up resources in the database itself. So the other technique I would consider here is &lt;a href="https://use-the-index-luke.com/no-offset"&gt;keyset pagination&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Keyset pagination works against any data that is ordered by a unique column - it works especially well against a primary key (or other indexed column). Each page of data is retrieved using a query something like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; items &lt;span class="pl-k"&gt;order by&lt;/span&gt; id &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;21&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Note the &lt;code&gt;limit 21&lt;/code&gt; - if we are retrieving pages of 20 items we ask for 21, since then we can use the last returned item to tell if there is a next page or not.&lt;/p&gt;
&lt;p&gt;Then for subsequent pages take the 20th &lt;code&gt;id&lt;/code&gt; value and ask for things greater than that:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;select&lt;/span&gt; &lt;span class="pl-k"&gt;*&lt;/span&gt; &lt;span class="pl-k"&gt;from&lt;/span&gt; items &lt;span class="pl-k"&gt;where&lt;/span&gt; id &lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; &lt;span class="pl-c1"&gt;20&lt;/span&gt; &lt;span class="pl-k"&gt;limit&lt;/span&gt; &lt;span class="pl-c1"&gt;21&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each of these queries is fast to respond (since it's against an ordered index) and uses a predictable, fixed amount of memory. Using keyset pagination we can loop through an abitrarily large table of data, streaming each page out one at a time, without exhausting any resources.&lt;/p&gt;
&lt;p&gt;And since each query is small and fast, we don't need to worry about huge queries tying up database resources either.&lt;/p&gt;
&lt;h4&gt;What can go wrong?&lt;/h4&gt;
&lt;p&gt;I really like these patterns. They haven't bitten me yet, though I've not deployed them for anything truly huge scale. So I &lt;a href="https://twitter.com/simonw/status/1405554676993433605"&gt;asked Twitter&lt;/a&gt; what kind of problems I should look for.&lt;/p&gt;
&lt;p&gt;Based on the Twitter conversation, here are some of the challenges that this approach faces.&lt;/p&gt;
&lt;h4&gt;Challenge: restarting servers&lt;/h4&gt;
&lt;blockquote&gt;
&lt;p dir="ltr" lang="en"&gt;If the stream takes a significantly long time to finish then rolling out updates becomes a problem. You don't want to interrupt a download but also don't want to wait forever for it to finish to spin down the server.&lt;/p&gt;— Adam Lowry (@robotadam) &lt;a href="https://twitter.com/robotadam/status/1405556544897384459?ref_src=twsrc%5Etfw"&gt;June 17, 2021&lt;/a&gt;
&lt;/blockquote&gt;
&lt;p&gt;This came up a few times, and is something I hadn't considered. If your deployment process involves restarting your servers (and it's hard to imagine one that doesn't) you need to take long-running connections into account when you do that. If there's a user half way through a 500MB stream you can either truncate their connection or wait for them to finish.&lt;/p&gt;
&lt;h4 id="challenge-errors"&gt;Challenge: how to return errors&lt;/h4&gt;
&lt;p&gt;If you're streaming a response, you start with an HTTP 200 code... but then what happens if an error occurs half-way through, potentially while paginating through the database?&lt;/p&gt;
&lt;p&gt;You've already started sending the request, so you can't change the status code to a 500. Instead, you need to write some kind of error to the stream that's being produced.&lt;/p&gt;
&lt;p&gt;If you're serving up a huge JSON document, you can at least make that JSON become invalid, which should indicate to your client that something went wrong.&lt;/p&gt;
&lt;p&gt;Formats like CSV are harder. How do you let your user know that their CSV data is incomplete?&lt;/p&gt;
&lt;p&gt;And what if someone's connection drops - are they definitely going to notice that they are missing something, or will they assume that the truncated file is all of the data?&lt;/p&gt;
&lt;h4&gt;Challenge: resumable downloads&lt;/h4&gt;
&lt;p&gt;If a user is paginating through your API, they get resumability for free: if something goes wrong they can start again at the last page that they fetched.&lt;/p&gt;
&lt;p&gt;Resuming a single stream is a lot harder.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests"&gt;HTTP range mechanism&lt;/a&gt; can be used to provide resumable downloads against large files, but it only works if you generate the entire file in advance.&lt;/p&gt;
&lt;p&gt;There is a way to design APIs to support this, provided the data in the stream is in a predictable order (which it has to be if you're using keyset pagination, described above).&lt;/p&gt;
&lt;p&gt;Have the endpoint that triggers the download take an optional &lt;code&gt;?since=&lt;/code&gt; parameter, like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /stream-everything?since=b24ou34
[
    {"id": "m442ecc", "name": "..."},
    {"id": "c663qo2", "name": "..."},
    {"id": "z434hh3", "name": "..."},
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here the &lt;code&gt;b24ou34&lt;/code&gt; is an identifier - it can be a deliberately opaque token, but it needs to be served up as part of the response.&lt;/p&gt;
&lt;p&gt;If the user is disconnected for any reason, they can start back where they left off by passing in the last ID that they successfully retrieved:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /stream-everything?since=z434hh3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This still requires some level of intelligence from the client application, but it's a reasonably simple pattern both to implement on the server and as a client.&lt;/p&gt;
&lt;h4&gt;Easiest solution: generate and return from cloud storage&lt;/h4&gt;
&lt;p&gt;It seems the most robust way to implement this kind of API is the least technically exciting: spin off a background task that generates the large response and pushes it to cloud storage (S3 or GCS), then redirect the user to a signed URL to download the resulting file.&lt;/p&gt;
&lt;p&gt;This is easy to scale, gives users complete files with content-length headers that they know they can download (and even resume-downloading, since range headers are supported by S3 and GCS). It also avoids any issues with server restarts caused by long connections.&lt;/p&gt;
&lt;p&gt;This is how Mixpanel handle their export feature, and it's &lt;a href="https://seancoates.com/blogs/lambda-payload-size-workaround"&gt;the solution Sean Coates came to&lt;/a&gt; when trying to find a workaround for the AWS Lambda/API Gate response size limit.&lt;/p&gt;
&lt;p&gt;If your goal is to provide your users a robust, reliable bulk-export mechanism for their data, export to cloud storage is probably the way to go.&lt;/p&gt;
&lt;p&gt;But streaming dynamic responses are a really neat trick, and I plan to keep exploring them!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/apis"&gt;apis&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/scaling"&gt;scaling&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/streaming"&gt;streaming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/asgi"&gt;asgi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="apis"/><category term="scaling"/><category term="streaming"/><category term="asgi"/><category term="http-range-requests"/></entry><entry><title>Hosting SQLite databases on Github Pages</title><link href="https://simonwillison.net/2021/May/2/hosting-sqlite-databases-on-github-pages/#atom-tag" rel="alternate"/><published>2021-05-02T18:55:36+00:00</published><updated>2021-05-02T18:55:36+00:00</updated><id>https://simonwillison.net/2021/May/2/hosting-sqlite-databases-on-github-pages/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://phiresky.github.io/blog/2021/hosting-sqlite-databases-on-github-pages/"&gt;Hosting SQLite databases on Github Pages&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I've seen the trick of running SQLite compiled to WASM in the browser &lt;a href="https://sql.js.org/"&gt;before&lt;/a&gt;, but here it comes with an incredibly clever bonus trick: it uses SQLite's page structure to fetch subsets of the database file via HTTP range requests, which means you can run indexed SQL queries against a 600MB database file while only fetching a few MBs of data over the wire. Absolutely brilliant. Tucked away at the end of the post is another neat trick: making the browser DOM available to SQLite as a virtual table, so you can query and update the DOM of the current page using SQL!

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=27016630"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;



</summary><category term="sqlite"/><category term="webassembly"/><category term="http-range-requests"/></entry><entry><title>Random access to Web audio</title><link href="https://simonwillison.net/2004/May/18/random/#atom-tag" rel="alternate"/><published>2004-05-18T22:03:45+00:00</published><updated>2004-05-18T22:03:45+00:00</updated><id>https://simonwillison.net/2004/May/18/random/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="http://weblog.infoworld.com/udell/2004/05/18.html#a1003"&gt;Random access to Web audio&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Standard MP3s can be randomly accessed using HTTP’s range header—without any extra server software.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/http-range-requests"&gt;http-range-requests&lt;/a&gt;&lt;/p&gt;



</summary><category term="http-range-requests"/></entry></feed>