Client Side Transport Maps on OpenStreetMap.org
Earlier this year, OpenStreetMap.org successfully migrated its Transport layer from our server-side rendered map tiles to our client-side rendered Vector Styles API. I gave a talk about it at State of the Map 2026 in Paris, walking through 11 of the real-world challenges I hit converting an existing, well-known Mapnik style to MapLibre — and how I solved them.
You can watch my talk in full below, or read the transcript underneath if you’d rather skim.
Main takeaways
- OpenStreetMap.org’s Transport layer now runs on our client-side, MapLibre-rendered Vector Styles API, not the previous server-side rendered tiles — and nobody noticed the switch.
- Converting a mature Mapnik style to MapLibre surfaced more than 11 real conversion challenges, from off-by-one zoom levels to genuine MapLibre limitation with no workarounds.
- Some of these challenges are already fixed upstream — layer opacity stacking was solved in MapLibre 6 — while others, like compositing operations, still have no solution.
- We use Glug, a Ruby DSL preprocessor built by Richard Fairhurst, to keep the Transport style’s 1,500-line MapLibre JSON stylesheet maintainable.
- The biggest challenge we will face when converting OpenCycleMap to client-side rendering will be MapLibre’s lack of support for the compositing operations we use for hill shading.
- Why I’m giving this talk
- Server-side versus client-side rendering
- Eleven challenges
- 1. Zoom levels are offset by one
- 2. Zoom levels are continuous
- 3. Layers blink during zoom transitions
- 4. Label layer priority is reversed
- 5. A layer can only be one type
- 6. The fill outline makes polygons slightly too big
- 7. Cascading versus expressions
- 8. Attachments
- 9. Instances
- 10. Layer opacity stacking (already solved)
- 11. Compositing only supports “src-over”
- Making it easier: Glug
- Questions and answers
Why I’m giving this talk
Earlier this year I was reviewing the pull requests and issues for the OpenStreetMap website, and I came across one from Tom Hughes and Marwin, discussing switching the Transport layer, which I design, from server-side rendering to client-side rendering. This made me pause for a moment, because I’d only finished making that client-side style less than two weeks previously, and the dark style the day before. I wasn’t entirely sure it was ready for primetime — openstreetmap.org has a lot of traffic, and there’s a lot of complexity in these styles. Had I got some of the conversions wrong? Got the colours wrong in the dark style? Not considered something, or were there performance issues on old devices? I didn’t know whether any of these were going to be a problem.
But we went ahead anyway and merged in the client-side rendering, and I braced myself for feedback and complaints. As the days went past, I didn’t get any feedback at all — not on the regular Transport style, not on the dark style. So I took this as a win. No news is good news, right? It appears that everything worked fine with this project.
So given that little summary — if you also have a server-side Mapnik-based style and you want to convert it to work on MapLibre, you might be thinking to yourself: well, how hard can it be? Let’s find out.
My name’s Andy Allan — you might know me as gravitystorm. I’m one of the volunteer maintainers for the OpenStreetMap website, but I also own and run a small cartography business called Thunderforest, which I set up to help commercialise and support the work I do on the Transport map, OpenCycleMap and the various other maps that I run.
Server-side versus client-side rendering
I want to start with some background. A lot of the discussion within the community focuses on the difference between raster tiles and vector tiles. Many people have a good idea that raster tiles are images where all the pixels are pre-coloured, and vector tiles are packets of data where the styling hasn’t yet been applied. But for this talk, that distinction is slightly confusing, so I’m going to stick to server-side and client-side as the two terms instead.
The OpenStreetMap Foundation’s tile server that generates the Standard layer runs using server-side rendering, using a library called Mapnik, with direct PostGIS queries. An alternative is to use vector tiles, but still do the rendering on the server side — and that’s what I’ve been doing since 2014. In fact, there’s a recording of a presentation I gave at State of the Map Europe in 2014, which was the point we switched the Transport map to using vector tiles — but vector tiles with server-side rendering.
This talk is about using the same vector tiles, but doing the rendering on the client side instead. There’s technically a fourth possibility — client-side rendering with direct PostGIS queries — but I’m not sure anybody’s ever tried that.
To be clear: both the server-side rendered tiles and the client-side rendered map come from the same database. It’s the same set of vector tiles, not tailored in any way. Mapnik sends PNGs across the network. On the client side, we send vector tiles across the network, and it’s MapLibre running in your browser that does the colouring in. Either way, you get the same map — same OpenStreetMap data, same choice of features shown, same colours.
Which opens up the question: if they’re so similar, why bother with this project at all? There are a few features that only work on the client side. The most noticeable involve panning and zooming — you can zoom in continuously instead of in discrete steps. You can also rotate the map without the labels ending up upside down. You can even tilt the map to get a bird’s-eye or slanting perspective. There are other things too, involving interactivity and label translations, but we’re not going to cover those here. It’s worth pointing out that rotation and tilting aren’t available today on openstreetmap.org — that would be a future project.
Eleven challenges
So I’m going to run through 11 challenges I faced during the conversion. Some of these are trivial, some were hard, and for some I don’t yet have a solution.
1. Zoom levels are offset by one
Let’s start with one of the seemingly trivial ones. These two maps show the same area at the same scale: the one on the left, rendered by Mapnik, is zoom level 14; the one on the right is zoom level 13. Every time you convert a style from Mapnik to MapLibre, you need to remember to subtract one from the zoom levels. You would be surprised how often I forgot to do this — it’s just endlessly frustrating looking at the result and going, “that’s not right, I’ve forgotten the zoom level thing again.”
2. Zoom levels are continuous
In Mapnik you’re used to saying things like “draw this country boundary at zoom level 5”. If you translate that straight across to MapLibre, that becomes a min-zoom of 4 and a max-zoom of 4. But MapLibre’s zoom levels are continuous — that range doesn’t include 3.99 or 4.01. Do a direct translation and you’ll briefly see the lines appear at exactly zoom level 4 and nowhere else. You need to rethink the way you treat zoom levels so that everything is defined as a range, even in Mapnik — then it translates much more easily, and you get a whole zoom level’s worth of range in MapLibre.
3. Layers blink during zoom transitions
The third one relating to zooms — I don’t know if there’s a proper term for it, maybe others have hit this and know — is that it’s quite common in cartography to have the same feature look different at low zooms and high zooms. Roads shown as a plain line at low zoom that get a casing when you zoom in, or railways that are just a black line but get white dashes as you zoom in. It’s common to treat these as separate layers — a low-zoom railway layer and a high-zoom railway layer, defined separately.
Port that straight across to MapLibre and you’ll hit a problem. When MapLibre changes zoom level, it makes three separate decisions: is this layer still needed (keep it), is it no longer needed (remove it), and are there new layers required (add them)? Those three things don’t happen simultaneously. So you get an animation where the low-zoom railways disappear, there’s a gap of a few milliseconds while it draws the high-zoom version, and you see the same blink — briefer — zooming back out.
You need to rethink your layers so a feature that starts showing at low zoom keeps showing continuously all the way through — you can adjust the width or colour, but it needs to stay visible the whole time, to stop that blinking effect of disappearing and coming back.
4. Label layer priority is reversed
When it comes to layer ordering, Mapnik works from the bottom up — you paint the background first, then other layers, typically polygons first and rivers later. With labels, you draw the high-priority ones first, and any gaps left over get filled by lower-priority labels.
MapLibre is similar, except the label priorities are swapped around — and I spent days trying to figure out why I couldn’t get the labels working properly. It’s the last label layer in MapLibre that has priority. I believe that’s so that if you add an overlay on top, it gets even higher priority — if you add your own data over the top, it removes labels underneath — but I never found any documentation confirming that.
5. A layer can only be one type
When it comes to layers, a MapLibre layer can only be of one type — you can’t draw a line and a symbol in the same layer, or a polygon and a line in the same layer. You have to choose a single type of rendering for that layer.
6. The fill outline makes polygons slightly too big
Now, those paying attention will have noticed that the fill layer type actually has an optional stroked border. Let’s talk about that. Eagle-eyed viewers will notice that with Mapnik on the left and MapLibre on the right, there are more green pixels on the right, despite it being the same data from the same vector tiles. It’s more obvious in the Netherlands — way more blue pixels on the right. That’s because that optional stroked outline is turned on by default. For obscure reasons involving WebGL, it can only be one pixel wide — you can change the colour but not the width — and only 0.5 pixels of it is actually visible, because the fill obscures the rest. The side effect is that it makes every polygon very slightly too big.
It was originally an anti-aliasing hack: when two polygons are close together and are meant to look continuous, there’d be little gaps between them, so the developers made every polygon very slightly too big so they overlap. In almost all circumstances you can just turn it off, and you’ll get polygons at the correct size — and you also save the performance cost of rendering the outline over and over. As a side note, if you have a polygon pattern fill, MapLibre draws that outline as a transparent line but still spends the time and effort drawing it around every pattern-filled polygon, so turn it off for pattern fills too.
7. Cascading versus expressions
In CartoCSS you’re used to cascading — based on attributes or zoom level, you change symbolizer attributes, like the file you want to render or the label text. MapLibre doesn’t have that concept. It has expressions instead — for each attribute, you specify, using what they call an expression, how you want that feature to appear. In my experience it works fine, but these expressions are very hard to read, write and understand.
8. Attachments
Two more features close to cascading — a quick primer on attachments versus instances. Both are used when you want to draw the same feature twice — two lines for each road, or two lines for each railway. With attachments, you draw your casings first, casing A then casing B, then the fills on top, and you get roads.
Attachments are straightforward — you just treat them as separate layers in MapLibre: a casing layer, then a fill layer, and it draws all the casing features first, then all the fills.
9. Instances
With instances, you apply all the line styles to each feature in turn — draw both the casing and the fill for feature A, then move on to the next feature.
Instances are not straightforward. In fact it’s not possible at all — there’s no way in MapLibre to apply multiple rules to the same feature and work through features one at a time. That’s the first case where there’s genuinely no option, just a built-in limitation.
So, faced with only the layer-by-layer option, you think: okay, draw the black lines first, then the white dashes on top. At first that looks fine — it looks like the effect you want. The problem comes when the railways are close together: because you’ve drawn all the black lines first and all the white dashes second, they blend into each other and look like a mess. In this case I had to come up with a workaround — three layers: black lines at the bottom, white lines in the middle, and black dashes over the top, because at least when the black dashes blend together it doesn’t look quite as bad. But this is a general missing feature — if you use instances anywhere in your Mapnik styles, you’ll need to come up with workarounds to get it working in MapLibre.
10. Layer opacity stacking (already solved)
The same kind of thing applies to layer opacity — being able to draw all the route highlights without the transparency stacking up was an issue, but they actually solved that in MapLibre 6, released a few weeks ago, so I don’t need to complain about that one.
11. Compositing only supports “src-over”
The eleventh and final one is compositing — a bit like stacking layers with different transparency on top, but instead of transparency, you choose the type of image processing you want for each layer. The default is what’s called “src-over”, putting the next layer on top of the one before — but you can do clever things: change the colours of layers below, brighten them, remove things, make them transparent. There are more than a dozen options in Mapnik, and I audited all my different styles and use six of these different composition operations. In Mapnik you can also apply it at the individual symbolizer level, not just the layer level.
MapLibre just has src-over. That’s it. You can only draw things on top of what came before — no alternatives, and currently no workaround, although there are discussions on how to add one.
So before you do any conversions, check whether you use any composition operations — if you do, you’ll need an entirely different plan. Thankfully, for the Transport layer I don’t use any, so it wasn’t a problem here — but I know the other styles you know me for do use them, and we’ll need a plan B for those.
Making it easier: Glug
There is one thing, through all of this, that makes your own life significantly easier when making MapLibre styles — whether it’s a conversion or a new style in general. The stylesheets MapLibre accepts are JSON files. The Transport style is 1,500 lines of JSON — one big file. It’s hard to find the layer you’re working on; if you want to change a specific colour, you need to find-and-replace across the whole file; getting all the braces and commas right is fiddly; and JSON doesn’t support comments by default. It’s not easy.
So instead, I use a program called Glug, which Richard Fairhurst — who’s sat here in the audience — created. It’s a stylesheet preprocessor. If you’ve written Mapnik XML and understood why CartoCSS was invented, this is the same idea, but for MapLibre JSON: a way of writing a stylesheet that’s much more user-friendly and developer-friendly, but compiles down to MapLibre JSON. You do the compilation either in CI or on your own laptop — customers and end users don’t get anything different, just the compiled JSON.
Glug has lots of helpful features: you can split your stylesheet over multiple files, add comments, use colour variables so you set a colour once and change it in one place, reorder layers, specify colour definitions, and use perceptual colour functions to darken and lighten colours in a perceptual colour space, even though MapLibre itself doesn’t support that — Glug does the calculation and outputs plain RGB. I worked on some of these features alongside building the Transport map, specifically to make my own life easier, plus some behind-the-scenes work like tests and CI.
It’s a Ruby DSL, so if you’ve used CartoCSS it’ll look very familiar — endless zoom ranges, the colours you want, colour functions, which source layers to use. I think everything you can do in MapLibre, you can specify in Glug.
And so that’s it — that’s the Transport map, rendered using MapLibre in your browser, a direct port from the server-side rendered style. Thanks very much for your attention.
Questions and answers
Audience: Regarding OpenCycleMap, are there plans to do the same thing regarding the zoom level? And is it scheduled to be updated with the new bicycle-related tags?
Andy: Yeah, so that’s two different questions. One is: am I planning on converting the server-side rendered OpenCycleMap to be client-side rendered? The answer is yes, and there are two big blockers. The first is layer opacity, which I use for the cycle route highlighting — that’s only just become available in MapLibre. The second is composition operations, which I use for the hill shading — instead of just drawing grey over the map to simulate a hill, which eventually makes the whole hill look grey, I use a composition operation called “hard light”, which changes the colour intensity of the slopes, so you get a dark green forest on a slope instead of just grey. I don’t yet have a workaround for that — I’m hoping we can work with the MapLibre developers to get it added.
The second half of the question was about changing tags, and that’s independent of any conversion — if I were to change the tags or the rendering, I might need to change the vector tiles, which are shared between both styles, and I’d need to make cartographic decisions that are also shared between both. So that’s not dependent on sorting out MapLibre — I’m happy to make changes to the OpenCycleMap style and what tags it interprets, independently.
Ben: Did you at some point consider adding special types of data or features to the vector tiles to make the transition easier?
Andy: Yes, it’s a good question. I haven’t come across anything yet where that would be a solution. I did investigate the instances problem, to see whether additional layer attributes would let me do something clever with the railways — re-sort them so each railway feature ends up in the data twice, and you could do something with that. That would be one solution, but I’d need to double the amount of railway data in the tile. The other place it comes up is composition operations — again, maybe there are some data changes depending on exactly what I’m doing; I might be able to change the data for some cases, though I don’t think so for the hill shading. But apart from those last few I mentioned, all the other challenges were really either me learning about MapLibre, or me reading the documentation more carefully — so I didn’t need any data changes, just a better understanding of how to approach it.
Richard: Have you found any differences in label placement behaviour between MapLibre and Mapnik — things you could do in Mapnik that you can’t in MapLibre?
Andy: Yeah, absolutely. One of the things Mapnik lets you do — though it’s not very well documented, and I’m not entirely sure I understand all the nuances — is that when you’re placing text, you get two buffer options: how much space around the text should prevent other labels appearing in future, and how far away from existing text you want this label to appear. It’s not consistent in Mapnik whether both of these are available, depending on whether you’re using a text symbolizer, shield symbolizer, marker symbolizer or point symbolizer — some have both, some only have one, and there are about three or four different names between them. The intention is there to let you do both, and it isn’t there in MapLibre — MapLibre only gives you one margin around your symbol layers, and I haven’t investigated enough to know whether that margin is the distance from other placed things, or the distance you want to keep new things away from.
Apart from that — which is a pretty in-depth response — I haven’t noticed many other differences. There are other challenges too that didn’t make it into the presentation — for example, writing text along a curved line is a bit of a nightmare in MapLibre, so there’s definitely some text-placement algorithm work that could be improved there too.
Audience: I’m interested to know how you go about identifying all the different variables that appear on OpenStreetMap for your filtering system — there are typos and near-correct tagging. How do you identify all those variables to make sure you get good coverage?
Andy: For tagging errors — like a capitalised key when it shouldn’t be — those just don’t show up on the maps I make. I don’t do any kind of normalisation. The other case is when there are genuine differences of opinion in the OpenStreetMap community about how to map something — for example, there are at least three different public transport mapping schemas relevant to these maps.
The way I learn about all of these is just through years of experience. I’ve got the map styles I’ve developed over the years, which give me a basis to build on. I get feedback from the community, which is usually the most common way I learn about new ways of mapping things. And I’ll read through the wiki, and occasionally the forum, to get an idea of which is the most common way of tagging something, or whether there’s a subtle difference between them. The third and final way — the one I like best — is personal experience: I like going around the world, visiting different places, and seeing whether their public transport shows up on my map. If something’s missing, that’s a good reason to go and look at why inclined lifts don’t appear, or public outdoor escalators, or things like that.
Andy Allan is the Founder and Chief Mapwrangler